From 2676a51647ab40bf92811e35566ad2bdd181df8f Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sun, 22 Feb 2026 21:39:50 -0600
Subject: [PATCH 001/474] refactor(ui): compose resources, domain layer (#4628)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
AGENTS.md | 18 +-
CONTRIBUTING.md | 8 +-
app/README.md | 2 +-
app/build.gradle.kts | 8 +-
.../java/com/geeksville/mesh/MainActivity.kt | 4 +-
.../usecase/GetDiscoveredDevicesUseCase.kt | 202 +++++++++
.../com/geeksville/mesh/model/UIViewModel.kt | 6 +-
.../mesh/navigation/NodesNavigation.kt | 20 +-
.../mesh/service/MeshConnectionManager.kt | 12 +-
.../mesh/service/MeshDataHandler.kt | 12 +-
.../mesh/service/MeshNeighborInfoHandler.kt | 6 +-
.../geeksville/mesh/service/MeshService.kt | 14 +-
.../service/MeshServiceNotificationsImpl.kt | 38 +-
.../mesh/service/MeshTracerouteHandler.kt | 12 +-
.../main/java/com/geeksville/mesh/ui/Main.kt | 38 +-
.../mesh/ui/connections/ConnectionsScreen.kt | 20 +-
.../mesh/ui/connections/ScannerViewModel.kt | 157 +------
.../ui/connections/components/BLEDevices.kt | 4 +-
.../components/ConnectingDeviceInfo.kt | 6 +-
.../components/ConnectionsSegmentedBar.kt | 11 +-
.../components/CurrentlyConnectedInfo.kt | 6 +-
.../connections/components/DeviceListItem.kt | 10 +-
.../connections/components/NetworkDevices.kt | 20 +-
.../ui/connections/components/UsbDevices.kt | 4 +-
.../mesh/ui/contact/AdaptiveContactsScreen.kt | 7 +-
.../geeksville/mesh/ui/contact/ContactItem.kt | 8 +-
.../geeksville/mesh/ui/contact/Contacts.kt | 42 +-
.../mesh/ui/contact/ContactsViewModel.kt | 4 +-
.../mesh/ui/node/AdaptiveNodeListScreen.kt | 4 +-
.../com/geeksville/mesh/ui/sharing/Channel.kt | 28 +-
.../com/geeksville/mesh/ui/sharing/Share.kt | 15 +-
app/src/main/res/values/strings.xml | 19 -
.../radio/NordicBleInterfaceDrainTest.kt | 161 -------
.../kotlin/org/meshtastic/buildlogic/Dokka.kt | 2 +-
.../kotlin/org/meshtastic/buildlogic/Kover.kt | 2 +-
core/barcode/README.md | 2 +-
core/barcode/build.gradle.kts | 2 +-
.../core/barcode/BarcodeScannerProvider.kt | 4 +-
.../core/barcode/BarcodeScannerProvider.kt | 4 +-
.../core/ble/BluetoothRepository.kt | 3 +-
.../repository/FirmwareReleaseRepository.kt | 8 +-
core/database/README.md | 2 +-
core/database/build.gradle.kts | 2 +-
.../meshtastic/core/database/model/Message.kt | 54 +--
.../core/database/model/NodeSortOption.kt | 19 +-
core/{strings => resources}/README.md | 10 +-
core/{strings => resources}/build.gradle.kts | 2 +-
.../meshtastic/core/resources}/ContextExt.kt | 2 +-
.../composeResources/drawable/ic_antenna.xml} | 0
.../drawable/ic_battery_alert.xml | 0
.../drawable/ic_battery_high.xml | 0
.../drawable/ic_battery_low.xml | 0
.../drawable/ic_battery_medium.xml | 0
.../drawable/ic_battery_outline.xml | 0
.../drawable/ic_battery_unknown.xml | 0
.../drawable/ic_counter_0.xml} | 0
.../drawable/ic_counter_1.xml} | 0
.../drawable/ic_counter_2.xml} | 0
.../drawable/ic_counter_3.xml} | 0
.../drawable/ic_counter_4.xml} | 0
.../drawable/ic_counter_5.xml} | 0
.../drawable/ic_counter_6.xml} | 0
.../drawable/ic_counter_7.xml} | 0
.../drawable/ic_counter_8.xml} | 0
.../drawable/ic_dew_point.xml | 0
.../drawable/ic_location_on.xml} | 0
.../drawable/ic_lock_open_right.xml} | 0
.../drawable/ic_map_location_dot.xml} | 0
.../drawable/ic_map_navigation.xml} | 0
.../drawable/ic_meshtastic.xml | 0
.../drawable/ic_mountain_flag.xml} | 0
.../drawable/ic_power_plug.xml} | 0
.../drawable/ic_radioactive.xml | 0
.../drawable/ic_soil_moisture.xml | 0
.../drawable/ic_soil_temperature.xml | 0
.../drawable/ic_unverified.xml | 0
.../composeResources/drawable/img_chirpy.xml} | 0
.../drawable/img_hw_unknown.xml | 0
.../composeResources/drawable/img_qrcode.png} | Bin
.../composeResources/values-ar/strings.xml | 0
.../composeResources/values-be/strings.xml | 0
.../composeResources/values-bg/strings.xml | 0
.../composeResources/values-ca/strings.xml | 0
.../composeResources/values-cs/strings.xml | 0
.../composeResources/values-de/strings.xml | 0
.../composeResources/values-el/strings.xml | 0
.../composeResources/values-es/strings.xml | 0
.../composeResources/values-et/strings.xml | 0
.../composeResources/values-fi/strings.xml | 0
.../composeResources/values-fr/strings.xml | 0
.../composeResources/values-ga/strings.xml | 0
.../composeResources/values-gl/strings.xml | 0
.../composeResources/values-he/strings.xml | 0
.../composeResources/values-hr/strings.xml | 0
.../composeResources/values-ht/strings.xml | 0
.../composeResources/values-hu/strings.xml | 0
.../composeResources/values-is/strings.xml | 0
.../composeResources/values-it/strings.xml | 0
.../composeResources/values-ja/strings.xml | 0
.../composeResources/values-ko/strings.xml | 0
.../composeResources/values-lt/strings.xml | 0
.../composeResources/values-nl/strings.xml | 0
.../composeResources/values-no/strings.xml | 0
.../composeResources/values-pl/strings.xml | 0
.../values-pt-rBR/strings.xml | 0
.../composeResources/values-pt/strings.xml | 0
.../composeResources/values-ro/strings.xml | 0
.../composeResources/values-ru/strings.xml | 0
.../composeResources/values-sk/strings.xml | 0
.../composeResources/values-sl/strings.xml | 0
.../composeResources/values-sq/strings.xml | 0
.../composeResources/values-sr/strings.xml | 0
.../composeResources/values-srp/strings.xml | 0
.../composeResources/values-sv/strings.xml | 0
.../composeResources/values-tr/strings.xml | 0
.../composeResources/values-uk/strings.xml | 0
.../values-zh-rCN/strings.xml | 0
.../values-zh-rTW/strings.xml | 0
.../composeResources/values/strings.xml | 0
.../org/meshtastic/core/resources/UiText.kt | 84 ++++
core/ui/README.md | 6 +-
core/ui/build.gradle.kts | 2 +-
.../core/ui/component/AlertDialogs.kt | 6 +-
.../core/ui/component/BitwisePreference.kt | 6 +-
.../core/ui/component/ChannelInfo.kt | 4 +-
.../core/ui/component/ContactSharing.kt | 4 +-
.../core/ui/component/CopyIconButton.kt | 7 +-
.../core/ui/component/DistanceInfo.kt | 4 +-
.../core/ui/component/EditBase64Preference.kt | 6 +-
.../core/ui/component/EditListPreference.kt | 14 +-
.../ui/component/EditPasswordPreference.kt | 9 +-
.../core/ui/component/EditTextPreference.kt | 7 +-
.../core/ui/component/ElevationInfo.kt | 6 +-
.../meshtastic/core/ui/component/HopsInfo.kt | 4 +-
.../meshtastic/core/ui/component/ImportFab.kt | 32 +-
.../core/ui/component/IndoorAirQuality.kt | 8 +-
.../core/ui/component/LastHeardInfo.kt | 11 +-
.../core/ui/component/LoraSignalIndicator.kt | 18 +-
.../core/ui/component/MainAppBar.kt | 19 +-
.../core/ui/component/MaterialBatteryInfo.kt | 4 +-
.../component/MaterialBluetoothSignalInfo.kt | 7 +-
.../core/ui/component/NodeKeyStatusIcon.kt | 26 +-
.../component/PositionPrecisionPreference.kt | 9 +-
.../meshtastic/core/ui/component/QrDialog.kt | 10 +-
.../core/ui/component/SatelliteCountInfo.kt | 4 +-
.../core/ui/component/SecurityIcon.kt | 32 +-
.../core/ui/component/SignalInfo.kt | 4 +-
.../core/ui/component/TelemetryInfo.kt | 26 +-
.../core/ui/component/TransportIcon.kt | 10 +-
.../org/meshtastic/core/ui/icon/Counter.kt | 38 +-
.../org/meshtastic/core/ui/icon/Device.kt | 7 +-
.../core/ui/qr/ScannedQrCodeDialog.kt | 16 +-
.../core/ui/share/SharedContactDialog.kt | 12 +-
.../org/meshtastic/core/ui/util/FormatAgo.kt | 8 +-
.../core/ui/util/ModelExtensions.kt | 26 +-
.../core/ui/util/ProtoExtensions.kt | 4 +-
crowdin.yml | 2 +-
feature/firmware/README.md | 2 +-
feature/firmware/build.gradle.kts | 2 +-
.../feature/firmware/FirmwareDfuService.kt | 6 +-
.../feature/firmware/FirmwareUpdateScreen.kt | 93 +++--
.../firmware/FirmwareUpdateViewModel.kt | 44 +-
.../feature/firmware/NordicDfuHandler.kt | 10 +-
.../feature/firmware/UsbUpdateHandler.kt | 10 +-
.../firmware/ota/Esp32OtaUpdateHandler.kt | 22 +-
feature/intro/README.md | 2 +-
feature/intro/build.gradle.kts | 2 +-
.../feature/intro/AnalyticsIntro.kt | 14 +-
.../feature/intro/AppIntroductionScreen.kt | 6 +-
.../feature/intro/BluetoothScreen.kt | 20 +-
.../feature/intro/CriticalAlertsScreen.kt | 13 +-
.../feature/intro/LocationScreen.kt | 31 +-
.../feature/intro/NotificationsScreen.kt | 24 +-
.../feature/intro/PermissionScreenLayout.kt | 7 +-
.../meshtastic/feature/intro/WelcomeScreen.kt | 23 +-
feature/map/README.md | 2 +-
feature/map/build.gradle.kts | 2 +-
.../org/meshtastic/feature/map/MapView.kt | 80 ++--
.../feature/map/MapViewExtensions.kt | 3 +-
.../feature/map/component/CacheLayout.kt | 13 +-
.../feature/map/component/DownloadButton.kt | 4 +-
.../map/component/EditWaypointDialog.kt | 24 +-
.../feature/map/component/MapButton.kt | 7 +-
.../fdroid/res/drawable/ic_location_on.xml | 14 +
.../res/drawable/ic_map_location_dot.xml | 11 +
.../fdroid/res/drawable/ic_map_navigation.xml | 11 +
.../org/meshtastic/feature/map/MapView.kt | 20 +-
.../map/component/ClusterItemsListDialog.kt | 9 +-
.../map/component/CustomMapLayersSheet.kt | 19 +-
.../CustomTileProviderManagerSheet.kt | 30 +-
.../map/component/EditWaypointDialog.kt | 24 +-
.../map/component/MapControlsOverlay.kt | 12 +-
.../map/component/MapFilterDropdown.kt | 13 +-
.../feature/map/component/MapTypeDropdown.kt | 17 +-
.../feature/map/component/WaypointMarkers.kt | 4 +-
.../feature/map/BaseMapViewModel.kt | 12 +-
.../org/meshtastic/feature/map/MapScreen.kt | 7 +-
feature/messaging/README.md | 2 +-
feature/messaging/build.gradle.kts | 4 +-
.../feature/messaging/DeliveryInfoDialog.kt | 8 +-
.../meshtastic/feature/messaging/Message.kt | 58 ++-
.../feature/messaging/MessageListPaged.kt | 117 ++++--
.../feature/messaging/MessageViewModel.kt | 73 +---
.../meshtastic/feature/messaging/QuickChat.kt | 24 +-
.../messaging/component/MessageActions.kt | 8 +-
.../component/MessageActionsBottomSheet.kt | 12 +-
.../messaging/component/MessageItem.kt | 10 +-
.../feature/messaging/component/Reaction.kt | 16 +-
.../domain/usecase/SendMessageUseCase.kt | 103 +++++
.../domain/usecase/SendMessageUseCaseTest.kt | 146 +++++++
feature/node/README.md | 2 +-
feature/node/build.gradle.kts | 2 +-
feature/node/component/DeviceActions.kt | 18 +-
.../node/component/AdministrationSection.kt | 18 +-
.../node/component/CompassBottomSheet.kt | 28 +-
.../feature/node/component/DeviceActions.kt | 16 +-
.../node/component/DeviceDetailsSection.kt | 26 +-
.../feature/node/component/DistanceInfo.kt | 4 +-
.../feature/node/component/ElevationInfo.kt | 6 +-
.../node/component/EnvironmentMetrics.kt | 50 ++-
.../component/FirmwareReleaseSheetContent.kt | 8 +-
.../feature/node/component/HopsInfo.kt | 4 +-
.../feature/node/component/InfoCard.kt | 14 +-
.../feature/node/component/LastHeardInfo.kt | 10 +-
.../node/component/LinkedCoordinatesItem.kt | 8 +-
.../node/component/NodeDetailComponents.kt | 4 +-
.../node/component/NodeDetailsSection.kt | 40 +-
.../node/component/NodeFilterTextField.kt | 24 +-
.../feature/node/component/NodeItem.kt | 16 +-
.../feature/node/component/NodeStatusIcons.kt | 18 +-
.../feature/node/component/NotesSection.kt | 8 +-
.../feature/node/component/PositionSection.kt | 8 +-
.../feature/node/component/PowerMetrics.kt | 8 +-
.../node/component/SatelliteCountInfo.kt | 4 +-
.../component/TelemetricActionsSection.kt | 12 +-
.../feature/node/component/TelemetryInfo.kt | 22 +-
.../feature/node/detail/NodeDetailScreen.kt | 20 +-
.../node/detail/NodeDetailViewModel.kt | 192 +--------
.../node/detail/NodeManagementActions.kt | 26 +-
.../feature/node/detail/NodeRequestActions.kt | 49 ++-
.../domain/usecase/GetFilteredNodesUseCase.kt | 60 +++
.../domain/usecase/GetNodeDetailsUseCase.kt | 237 +++++++++++
.../feature/node/list/NodeListScreen.kt | 22 +-
.../feature/node/list/NodeListViewModel.kt | 35 +-
.../feature/node/metrics/BaseMetricChart.kt | 6 +-
.../feature/node/metrics/CommonCharts.kt | 12 +-
.../feature/node/metrics/DeviceMetrics.kt | 21 +-
.../feature/node/metrics/EnvironmentCharts.kt | 18 +-
.../node/metrics/EnvironmentMetrics.kt | 31 +-
.../feature/node/metrics/HostMetricsLog.kt | 15 +-
.../feature/node/metrics/MetricsViewModel.kt | 393 +++++-------------
.../feature/node/metrics/NeighborInfoLog.kt | 15 +-
.../feature/node/metrics/PaxMetrics.kt | 17 +-
.../feature/node/metrics/PositionLog.kt | 23 +-
.../feature/node/metrics/PowerMetrics.kt | 17 +-
.../feature/node/metrics/SignalMetrics.kt | 15 +-
.../feature/node/metrics/TracerouteLog.kt | 35 +-
.../node/metrics/TracerouteMapScreen.kt | 10 +-
.../meshtastic/feature/node/model/LogsType.kt | 22 +-
.../feature/node/model/MetricInfo.kt | 7 +-
.../feature/node/model/TimeFrame.kt | 14 +-
.../usecase/GetFilteredNodesUseCaseTest.kt | 119 ++++++
.../node/metrics/BaseMetricScreenTest.kt | 4 +-
feature/settings/README.md | 2 +-
feature/settings/build.gradle.kts | 2 +-
.../settings/debugging/DebugFiltersTest.kt | 8 +-
.../settings/debugging/DebugSearchTest.kt | 10 +-
.../component/EditDeviceProfileDialogTest.kt | 8 +-
.../component/MapReportingPreferenceTest.kt | 10 +-
.../feature/settings/AboutScreen.kt | 4 +-
.../feature/settings/SettingsScreen.kt | 54 +--
.../feature/settings/debugging/Debug.kt | 32 +-
.../settings/debugging/DebugFilters.kt | 20 +-
.../feature/settings/debugging/DebugSearch.kt | 12 +-
.../settings/debugging/DebugViewModel.kt | 6 +-
.../settings/filter/FilterSettingsScreen.kt | 24 +-
.../settings/navigation/ConfigRoute.kt | 22 +-
.../settings/navigation/ModuleRoute.kt | 30 +-
.../settings/radio/CleanNodeDatabaseScreen.kt | 14 +-
.../radio/CleanNodeDatabaseViewModel.kt | 8 +-
.../feature/settings/radio/RadioConfig.kt | 36 +-
.../settings/radio/RadioConfigViewModel.kt | 8 +-
.../feature/settings/radio/ResponseState.kt | 2 +-
.../radio/channel/ChannelConfigScreen.kt | 14 +-
.../radio/channel/component/ChannelCard.kt | 4 +-
.../channel/component/ChannelConfigHeader.kt | 11 +-
.../radio/channel/component/ChannelLegend.kt | 34 +-
.../channel/component/EditChannelDialog.kt | 14 +-
.../AmbientLightingConfigItemList.kt | 16 +-
.../radio/component/AudioConfigItemList.kt | 20 +-
.../component/BluetoothConfigItemList.kt | 12 +-
.../component/CannedMessageConfigItemList.kt | 30 +-
.../DetectionSensorConfigItemList.kt | 22 +-
.../radio/component/DeviceConfigItemList.kt | 88 ++--
.../radio/component/DisplayConfigItemList.kt | 50 +--
.../component/EditDeviceProfileDialog.kt | 18 +-
.../ExternalNotificationConfigItemList.kt | 48 +--
.../radio/component/LoRaConfigItemList.kt | 48 +--
.../radio/component/MQTTConfigItemList.kt | 28 +-
.../radio/component/MapReportingPreference.kt | 19 +-
.../component/NeighborInfoConfigItemList.kt | 14 +-
.../radio/component/NetworkConfigItemList.kt | 58 +--
.../component/PacketResponseStateDialog.kt | 8 +-
.../component/PaxcounterConfigItemList.kt | 14 +-
.../radio/component/PositionConfigItemList.kt | 50 +--
.../radio/component/PowerConfigItemList.kt | 24 +-
.../radio/component/RadioConfigScreenList.kt | 6 +-
.../component/RangeTestConfigItemList.kt | 12 +-
.../component/RemoteHardwareConfigItemList.kt | 12 +-
.../radio/component/SecurityConfigItemList.kt | 46 +-
.../radio/component/SerialConfigItemList.kt | 18 +-
.../component/ShutdownConfirmationDialog.kt | 10 +-
.../component/StatusMessageConfigItemList.kt | 10 +-
.../component/StoreForwardConfigItemList.kt | 18 +-
.../component/TelemetryConfigItemList.kt | 30 +-
.../radio/component/UserConfigItemList.kt | 22 +-
.../settings/radio/component/WarningDialog.kt | 6 +-
.../settings/util/FixedUpdateIntervals.kt | 12 +-
.../feature/settings/util/LanguageUtils.kt | 12 +-
.../feature/settings/util/UiText.kt | 34 --
.../feature/settings/HomoglyphSettingTest.kt | 6 +-
settings.gradle.kts | 2 +-
322 files changed, 3031 insertions(+), 2790 deletions(-)
create mode 100644 app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
delete mode 100644 app/src/main/res/values/strings.xml
delete mode 100644 app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceDrainTest.kt
rename core/{strings => resources}/README.md (78%)
rename core/{strings => resources}/build.gradle.kts (94%)
rename core/{strings/src/androidMain/kotlin/org/meshtastic/core/strings => resources/src/androidMain/kotlin/org/meshtastic/core/resources}/ContextExt.kt (97%)
rename core/{ui/src/main/res/drawable/ic_antenna_24.xml => resources/src/commonMain/composeResources/drawable/ic_antenna.xml} (100%)
rename core/{ui/src/main/res => resources/src/commonMain/composeResources}/drawable/ic_battery_alert.xml (100%)
rename core/{ui/src/main/res => resources/src/commonMain/composeResources}/drawable/ic_battery_high.xml (100%)
rename core/{ui/src/main/res => resources/src/commonMain/composeResources}/drawable/ic_battery_low.xml (100%)
rename core/{ui/src/main/res => resources/src/commonMain/composeResources}/drawable/ic_battery_medium.xml (100%)
rename core/{ui/src/main/res => resources/src/commonMain/composeResources}/drawable/ic_battery_outline.xml (100%)
rename core/{ui/src/main/res => resources/src/commonMain/composeResources}/drawable/ic_battery_unknown.xml (100%)
rename core/{ui/src/main/res/drawable/counter_0_24px.xml => resources/src/commonMain/composeResources/drawable/ic_counter_0.xml} (100%)
rename core/{ui/src/main/res/drawable/counter_1_24px.xml => resources/src/commonMain/composeResources/drawable/ic_counter_1.xml} (100%)
rename core/{ui/src/main/res/drawable/counter_2_24px.xml => resources/src/commonMain/composeResources/drawable/ic_counter_2.xml} (100%)
rename core/{ui/src/main/res/drawable/counter_3_24px.xml => resources/src/commonMain/composeResources/drawable/ic_counter_3.xml} (100%)
rename core/{ui/src/main/res/drawable/counter_4_24px.xml => resources/src/commonMain/composeResources/drawable/ic_counter_4.xml} (100%)
rename core/{ui/src/main/res/drawable/counter_5_24px.xml => resources/src/commonMain/composeResources/drawable/ic_counter_5.xml} (100%)
rename core/{ui/src/main/res/drawable/counter_6_24px.xml => resources/src/commonMain/composeResources/drawable/ic_counter_6.xml} (100%)
rename core/{ui/src/main/res/drawable/counter_7_24px.xml => resources/src/commonMain/composeResources/drawable/ic_counter_7.xml} (100%)
rename core/{ui/src/main/res/drawable/counter_8_24px.xml => resources/src/commonMain/composeResources/drawable/ic_counter_8.xml} (100%)
rename feature/node/src/main/res/drawable/ic_outlined_dew_point_24.xml => core/resources/src/commonMain/composeResources/drawable/ic_dew_point.xml (100%)
rename core/{ui/src/main/res/drawable/ic_baseline_location_on_24.xml => resources/src/commonMain/composeResources/drawable/ic_location_on.xml} (100%)
rename core/{ui/src/main/res/drawable/ic_lock_open_right_24.xml => resources/src/commonMain/composeResources/drawable/ic_lock_open_right.xml} (100%)
rename core/{ui/src/main/res/drawable/ic_map_location_dot_24.xml => resources/src/commonMain/composeResources/drawable/ic_map_location_dot.xml} (100%)
rename core/{ui/src/main/res/drawable/ic_map_navigation_24.xml => resources/src/commonMain/composeResources/drawable/ic_map_navigation.xml} (100%)
rename core/{ui/src/main/res => resources/src/commonMain/composeResources}/drawable/ic_meshtastic.xml (100%)
rename core/{ui/src/main/res/drawable/mountain_flag_24px.xml => resources/src/commonMain/composeResources/drawable/ic_mountain_flag.xml} (100%)
rename core/{ui/src/main/res/drawable/ic_power_plug_24.xml => resources/src/commonMain/composeResources/drawable/ic_power_plug.xml} (100%)
rename feature/node/src/main/res/drawable/ic_filled_radioactive_24.xml => core/resources/src/commonMain/composeResources/drawable/ic_radioactive.xml (100%)
rename feature/node/src/main/res/drawable/soil_moisture.xml => core/resources/src/commonMain/composeResources/drawable/ic_soil_moisture.xml (100%)
rename feature/node/src/main/res/drawable/soil_temperature.xml => core/resources/src/commonMain/composeResources/drawable/ic_soil_temperature.xml (100%)
rename feature/node/src/main/res/drawable/unverified.xml => core/resources/src/commonMain/composeResources/drawable/ic_unverified.xml (100%)
rename core/{ui/src/main/res/drawable/chirpy.xml => resources/src/commonMain/composeResources/drawable/img_chirpy.xml} (100%)
rename feature/node/src/main/res/drawable/hw_unknown.xml => core/resources/src/commonMain/composeResources/drawable/img_hw_unknown.xml (100%)
rename core/{ui/src/main/res/drawable-nodpi/qrcode.png => resources/src/commonMain/composeResources/drawable/img_qrcode.png} (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-ar/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-be/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-bg/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-ca/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-cs/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-de/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-el/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-es/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-et/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-fi/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-fr/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-ga/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-gl/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-he/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-hr/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-ht/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-hu/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-is/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-it/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-ja/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-ko/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-lt/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-nl/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-no/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-pl/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-pt-rBR/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-pt/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-ro/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-ru/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-sk/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-sl/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-sq/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-sr/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-srp/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-sv/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-tr/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-uk/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-zh-rCN/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values-zh-rTW/strings.xml (100%)
rename core/{strings => resources}/src/commonMain/composeResources/values/strings.xml (100%)
create mode 100644 core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/UiText.kt
create mode 100644 feature/map/src/fdroid/res/drawable/ic_location_on.xml
create mode 100644 feature/map/src/fdroid/res/drawable/ic_map_location_dot.xml
create mode 100644 feature/map/src/fdroid/res/drawable/ic_map_navigation.xml
create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCase.kt
create mode 100644 feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCaseTest.kt
create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt
create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/domain/usecase/GetNodeDetailsUseCase.kt
create mode 100644 feature/node/src/test/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt
delete mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/util/UiText.kt
diff --git a/AGENTS.md b/AGENTS.md
index ae128ca37..69027f403 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,13 +32,13 @@ This file serves as a comprehensive guide for AI agents and developers working o
- **Material 3:** The app uses Material 3. Look for ways to use **Material 3 Expressive** components where appropriate.
- **Strings:**
- Do **not** use `app/src/main/res/values/strings.xml` for UI strings.
- - Use the **Compose Multiplatform Resource** library in `core/strings`.
- - **Definition:** Add strings to `core/strings/src/commonMain/composeResources/values/strings.xml`.
+ - Use the **Compose Multiplatform Resource** library in `core:resources`.
+ - **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Usage:**
```kotlin
import org.jetbrains.compose.resources.stringResource
- import org.meshtastic.core.strings.Res
- import org.meshtastic.core.strings.your_string_key
+ import org.meshtastic.core.resources.Res
+ import org.meshtastic.core.resources.your_string_key
Text(text = stringResource(Res.string.your_string_key))
```
@@ -102,7 +102,7 @@ This file serves as a comprehensive guide for AI agents and developers working o
1. **Explore First:** Before making changes, read `gradle/libs.versions.toml` and the relevant `build.gradle.kts` to understand the environment.
2. **Plan:** Identify which modules (`core` or `feature`) need modification.
3. **Implement:**
- - If adding a string, modify `core/strings`.
+ - If adding a string, modify `core:resources`.
- If adding a dependency, modify `libs.versions.toml` first.
4. **Verify:**
- Run `./gradlew spotlessApply` (Essential!).
@@ -118,8 +118,14 @@ This file serves as a comprehensive guide for AI agents and developers working o
## 7. Troubleshooting
-- **Missing Strings:** If `Res.string.xyz` is unresolved, ensure you have imported `org.meshtastic.core.strings.Res` and the specific string property, and that you have run a build to generate the resources.
+- **Missing Strings:** If `Res.string.xyz` is unresolved, ensure you have imported `org.meshtastic.core.resources.Res` and the specific string property, and that you have run a build to generate the resources.
- **Build Errors:** Check `gradle/libs.versions.toml` for version conflicts. Use `build-logic` conventions to ensure plugins are applied correctly.
---
*Refer to `CONTRIBUTING.md` for human-centric processes like Code of Conduct and Pull Request etiquette.*
+
+### E. Resources and Assets
+- **Centralization:** All global app resources (Strings, Drawables, Fonts, raw files) should be placed in `:core:resources`.
+- **Module Path:** `core/resources/src/commonMain/composeResources/`
+- **Decentralization:** Feature-specific strings and assets can (and should) be housed in their respective feature module's `composeResources` directory to maintain modular boundaries and clean architectural dependency graphs. Crowdin localization handles globbing `/**/composeResources/values/strings.xml` perfectly.
+- **Drawables:** Use `painterResource(Res.drawable.your_icon)` to access cross-platform drawables. Name them consistently (`ic_` for icons, `img_` for artwork). Avoid putting standard Drawables or Vectors in legacy Android `res/drawable` folders unless strictly required by a legacy library (like `OsmDroid` map markers) or the OS layer (like `app_icon.xml`).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b47041ceb..d64fe9976 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -19,14 +19,14 @@ Thank you for your interest in contributing to Meshtastic-Android! We welcome co
- Write clear, descriptive variable and function names.
- Add comments where necessary, especially for complex logic.
- Keep methods and classes focused and concise.
-- **Strings:** Use localised strings via the **Compose Multiplatform Resource** library in `:core:strings`.
+- **Strings:** Use localised strings via the **Compose Multiplatform Resource** library in `:core:resources`.
- Do **not** use the legacy `app/src/main/res/values/strings.xml`.
- - **Definition:** Add strings to `core/strings/src/commonMain/composeResources/values/strings.xml`.
+ - **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Usage:**
```kotlin
import org.jetbrains.compose.resources.stringResource
- import org.meshtastic.core.strings.Res
- import org.meshtastic.core.strings.your_string_key
+ import org.meshtastic.core.resources.Res
+ import org.meshtastic.core.resources.your_string_key
Text(text = stringResource(Res.string.your_string_key))
```
diff --git a/app/README.md b/app/README.md
index 6dd8c1ca7..d61f3a418 100644
--- a/app/README.md
+++ b/app/README.md
@@ -39,7 +39,7 @@ graph TB
:app -.-> :core:prefs
:app -.-> :core:proto
:app -.-> :core:service
- :app -.-> :core:strings
+ :app -.-> :core:resources
:app -.-> :core:ui
:app -.-> :core:barcode
:app -.-> :feature:intro
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7503e1f7b..1743e37bc 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -188,11 +188,9 @@ secrets {
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
- variant.flavorName?.let { flavor ->
- variant.applicationId = "com.geeksville.mesh.$flavor.debug"
- }
+ variant.flavorName?.let { flavor -> variant.applicationId = "com.geeksville.mesh.$flavor.debug" }
}
-
+
onVariants(selector().withBuildType("release")) { variant ->
if (variant.flavorName == "google") {
val variantNameCapped = variant.name.replaceFirstChar { it.uppercase() }
@@ -226,7 +224,7 @@ dependencies {
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
- implementation(projects.core.strings)
+ implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.barcode)
implementation(projects.feature.intro)
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index ef8225838..3b5dffc1e 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -50,8 +50,8 @@ import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import no.nordicsemi.kotlin.ble.environment.android.compose.LocalEnvironmentOwner
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.channel_invalid
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.showToast
diff --git a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt b/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
new file mode 100644
index 000000000..a6759dae6
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
@@ -0,0 +1,202 @@
+/*
+ * 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 com.geeksville.mesh.domain.usecase
+
+import android.hardware.usb.UsbManager
+import android.net.nsd.NsdServiceInfo
+import com.geeksville.mesh.model.DeviceListEntry
+import com.geeksville.mesh.model.getMeshtasticShortName
+import com.geeksville.mesh.repository.network.NetworkRepository
+import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
+import com.geeksville.mesh.repository.radio.RadioInterfaceService
+import com.geeksville.mesh.repository.usb.UsbRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import org.jetbrains.compose.resources.getString
+import org.meshtastic.core.ble.BluetoothRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.DatabaseManager
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.datastore.RecentAddressesDataSource
+import org.meshtastic.core.datastore.model.RecentAddress
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.meshtastic
+import java.util.Locale
+import javax.inject.Inject
+
+data class DiscoveredDevices(
+ val bleDevices: List,
+ val usbDevices: List,
+ val discoveredTcpDevices: List,
+ val recentTcpDevices: List,
+)
+
+@Suppress("LongParameterList")
+class GetDiscoveredDevicesUseCase
+@Inject
+constructor(
+ private val bluetoothRepository: BluetoothRepository,
+ private val networkRepository: NetworkRepository,
+ private val recentAddressesDataSource: RecentAddressesDataSource,
+ private val nodeRepository: NodeRepository,
+ private val databaseManager: DatabaseManager,
+ private val usbRepository: UsbRepository,
+ private val radioInterfaceService: RadioInterfaceService,
+ private val usbManagerLazy: dagger.Lazy,
+) {
+ private val suffixLength = 4
+
+ @Suppress("LongMethod", "CyclomaticComplexMethod")
+ fun invoke(showMock: Boolean): Flow {
+ val nodeDb = nodeRepository.nodeDBbyNum
+
+ val bondedBleFlow = bluetoothRepository.state.map { ble -> ble.bondedDevices.map { DeviceListEntry.Ble(it) } }
+
+ val processedTcpFlow =
+ combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) {
+ tcpServices,
+ recentList,
+ ->
+ val recentMap = recentList.associateBy({ it.address }) { it.name }
+ tcpServices
+ .map { service ->
+ val address = "t${service.toAddressString()}"
+ val txtRecords = service.attributes
+ val shortNameBytes = txtRecords["shortname"]
+ val idBytes = txtRecords["id"]
+
+ val shortName =
+ shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
+ val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
+ var displayName = recentMap[address] ?: shortName
+ if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
+ displayName += "_$deviceId"
+ }
+ DeviceListEntry.Tcp(displayName, address)
+ }
+ .sortedBy { it.name }
+ }
+
+ val usbDevicesFlow =
+ usbRepository.serialDevices.map { usb ->
+ usb.map { (_, d) -> DeviceListEntry.Usb(radioInterfaceService, usbManagerLazy.get(), d) }
+ }
+
+ return combine(
+ nodeDb,
+ bondedBleFlow,
+ processedTcpFlow,
+ usbDevicesFlow,
+ networkRepository.resolvedList,
+ recentAddressesDataSource.recentAddresses,
+ ) { args: Array ->
+ @Suppress("UNCHECKED_CAST", "MagicNumber")
+ val db = args[0] as Map
+
+ @Suppress("UNCHECKED_CAST", "MagicNumber")
+ val bondedBle = args[1] as List
+
+ @Suppress("UNCHECKED_CAST", "MagicNumber")
+ val processedTcp = args[2] as List
+
+ @Suppress("UNCHECKED_CAST", "MagicNumber")
+ val usbDevices = args[3] as List
+
+ @Suppress("UNCHECKED_CAST", "MagicNumber")
+ val resolved = args[4] as List
+
+ @Suppress("UNCHECKED_CAST", "MagicNumber")
+ val recentList = args[5] as List
+
+ val bleForUi =
+ bondedBle
+ .map { entry ->
+ val matchingNode =
+ if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
+ db.values.find { node ->
+ val suffix = entry.peripheral.getMeshtasticShortName()?.lowercase(Locale.ROOT)
+ suffix != null && node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
+ }
+ } else {
+ null
+ }
+ entry.copy(node = matchingNode)
+ }
+ .sortedBy { it.name }
+
+ val usbForUi =
+ (usbDevices + if (showMock) listOf(DeviceListEntry.Mock("Demo Mode")) else emptyList()).map { entry ->
+ val matchingNode =
+ if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
+ db.values.find { node ->
+ val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
+ suffix != null &&
+ suffix.length >= suffixLength &&
+ node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
+ }
+ } else {
+ null
+ }
+ entry.copy(node = matchingNode)
+ }
+
+ val discoveredTcpForUi =
+ processedTcp.map { entry ->
+ val matchingNode =
+ if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
+ val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
+ val deviceId = resolvedService?.attributes?.get("id")?.let { String(it, Charsets.UTF_8) }
+ db.values.find { node ->
+ node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
+ }
+ } else {
+ null
+ }
+ entry.copy(node = matchingNode)
+ }
+
+ val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
+ val recentTcpForUi =
+ recentList
+ .filterNot { discoveredTcpAddresses.contains(it.address) }
+ .map { DeviceListEntry.Tcp(it.name, it.address) }
+ .map { entry ->
+ val matchingNode =
+ if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
+ val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
+ db.values.find { node ->
+ suffix != null &&
+ suffix.length >= suffixLength &&
+ node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
+ }
+ } else {
+ null
+ }
+ entry.copy(node = matchingNode)
+ }
+ .sortedBy { it.name }
+
+ DiscoveredDevices(
+ bleDevices = bleForUi,
+ usbDevices = usbForUi,
+ discoveredTcpDevices = discoveredTcpForUi,
+ recentTcpDevices = recentTcpForUi,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
index 87e051932..52ef78ce5 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
@@ -53,13 +53,13 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.dispatchMeshtasticUri
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.client_notification
+import org.meshtastic.core.resources.compromised_keys
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.client_notification
-import org.meshtastic.core.strings.compromised_keys
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.ComposableContent
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
index 0a8e50f34..d9fded5b4 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt
@@ -46,16 +46,16 @@ import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.device
-import org.meshtastic.core.strings.environment
-import org.meshtastic.core.strings.host
-import org.meshtastic.core.strings.neighbor_info
-import org.meshtastic.core.strings.pax
-import org.meshtastic.core.strings.position_log
-import org.meshtastic.core.strings.power
-import org.meshtastic.core.strings.signal
-import org.meshtastic.core.strings.traceroute
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.device
+import org.meshtastic.core.resources.environment
+import org.meshtastic.core.resources.host
+import org.meshtastic.core.resources.neighbor_info
+import org.meshtastic.core.resources.pax
+import org.meshtastic.core.resources.position_log
+import org.meshtastic.core.resources.power
+import org.meshtastic.core.resources.signal
+import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.map.node.NodeMapScreen
import org.meshtastic.feature.map.node.NodeMapViewModel
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 4512b7a7d..f74668425 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
@@ -36,14 +36,14 @@ 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.prefs.ui.UiPrefs
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.connected_count
+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.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.connected_count
-import org.meshtastic.core.strings.connecting
-import org.meshtastic.core.strings.device_sleeping
-import org.meshtastic.core.strings.disconnected
-import org.meshtastic.core.strings.getString
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
index bc880d411..36338d493 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
@@ -43,15 +43,15 @@ import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.prefs.mesh.MeshPrefs
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.critical_alert
+import org.meshtastic.core.resources.error_duty_cycle
+import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.unknown_username
+import org.meshtastic.core.resources.waypoint_received
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.filter.MessageFilterService
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.critical_alert
-import org.meshtastic.core.strings.error_duty_cycle
-import org.meshtastic.core.strings.getString
-import org.meshtastic.core.strings.unknown_username
-import org.meshtastic.core.strings.waypoint_received
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt
index 37694ada0..3574bf6e1 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt
@@ -21,10 +21,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.getString
-import org.meshtastic.core.strings.unknown_username
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import java.util.Locale
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index 34e1adf4d..db1a6066f 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -107,7 +107,19 @@ class MeshService : Service() {
}
override fun onCreate() {
- super.onCreate()
+ try {
+ super.onCreate()
+ } catch (e: IllegalStateException) {
+ // Hilt can throw IllegalStateException in tests if the component is not created.
+ // This can happen if the service is started by the system (e.g. after a crash or on boot)
+ // before the test rule has a chance to create the component.
+ if (e.message?.contains("HiltAndroidRule") == true) {
+ Logger.w(e) { "MeshService created before Hilt component was ready in test. Stopping service." }
+ stopSelf()
+ return
+ }
+ throw e
+ }
Logger.i { "Creating mesh service" }
serviceNotifications.initChannels()
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 62fe766e1..0a37174ee 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
@@ -53,27 +53,27 @@ import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime
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.low_battery_message
+import org.meshtastic.core.resources.low_battery_title
+import org.meshtastic.core.resources.mark_as_read
+import org.meshtastic.core.resources.meshtastic_alerts_notifications
+import org.meshtastic.core.resources.meshtastic_app_name
+import org.meshtastic.core.resources.meshtastic_broadcast_notifications
+import org.meshtastic.core.resources.meshtastic_low_battery_notifications
+import org.meshtastic.core.resources.meshtastic_low_battery_temporary_remote_notifications
+import org.meshtastic.core.resources.meshtastic_messages_notifications
+import org.meshtastic.core.resources.meshtastic_new_nodes_notifications
+import org.meshtastic.core.resources.meshtastic_service_notifications
+import org.meshtastic.core.resources.meshtastic_waypoints_notifications
+import org.meshtastic.core.resources.new_node_seen
+import org.meshtastic.core.resources.no_local_stats
+import org.meshtastic.core.resources.reply
+import org.meshtastic.core.resources.you
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.client_notification
-import org.meshtastic.core.strings.getString
-import org.meshtastic.core.strings.low_battery_message
-import org.meshtastic.core.strings.low_battery_title
-import org.meshtastic.core.strings.mark_as_read
-import org.meshtastic.core.strings.meshtastic_alerts_notifications
-import org.meshtastic.core.strings.meshtastic_app_name
-import org.meshtastic.core.strings.meshtastic_broadcast_notifications
-import org.meshtastic.core.strings.meshtastic_low_battery_notifications
-import org.meshtastic.core.strings.meshtastic_low_battery_temporary_remote_notifications
-import org.meshtastic.core.strings.meshtastic_messages_notifications
-import org.meshtastic.core.strings.meshtastic_new_nodes_notifications
-import org.meshtastic.core.strings.meshtastic_service_notifications
-import org.meshtastic.core.strings.meshtastic_waypoints_notifications
-import org.meshtastic.core.strings.new_node_seen
-import org.meshtastic.core.strings.no_local_stats
-import org.meshtastic.core.strings.reply
-import org.meshtastic.core.strings.you
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt
index d03c3042a..0ca3e3947 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt
@@ -26,14 +26,14 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.traceroute_duration
+import org.meshtastic.core.resources.traceroute_route_back_to_us
+import org.meshtastic.core.resources.traceroute_route_towards_dest
+import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.getString
-import org.meshtastic.core.strings.traceroute_duration
-import org.meshtastic.core.strings.traceroute_route_back_to_us
-import org.meshtastic.core.strings.traceroute_route_towards_dest
-import org.meshtastic.core.strings.unknown_username
import org.meshtastic.proto.MeshPacket
import java.util.Locale
import javax.inject.Inject
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 170926b7c..bc3b82a6d 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -106,26 +106,26 @@ import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.app_too_old
+import org.meshtastic.core.resources.bottom_nav_settings
+import org.meshtastic.core.resources.connected
+import org.meshtastic.core.resources.connecting
+import org.meshtastic.core.resources.connections
+import org.meshtastic.core.resources.conversations
+import org.meshtastic.core.resources.device_sleeping
+import org.meshtastic.core.resources.disconnected
+import org.meshtastic.core.resources.firmware_old
+import org.meshtastic.core.resources.firmware_too_old
+import org.meshtastic.core.resources.map
+import org.meshtastic.core.resources.must_update
+import org.meshtastic.core.resources.nodes
+import org.meshtastic.core.resources.okay
+import org.meshtastic.core.resources.should_update
+import org.meshtastic.core.resources.should_update_firmware
+import org.meshtastic.core.resources.traceroute
+import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.app_too_old
-import org.meshtastic.core.strings.bottom_nav_settings
-import org.meshtastic.core.strings.connected
-import org.meshtastic.core.strings.connecting
-import org.meshtastic.core.strings.connections
-import org.meshtastic.core.strings.conversations
-import org.meshtastic.core.strings.device_sleeping
-import org.meshtastic.core.strings.disconnected
-import org.meshtastic.core.strings.firmware_old
-import org.meshtastic.core.strings.firmware_too_old
-import org.meshtastic.core.strings.map
-import org.meshtastic.core.strings.must_update
-import org.meshtastic.core.strings.nodes
-import org.meshtastic.core.strings.okay
-import org.meshtastic.core.strings.should_update
-import org.meshtastic.core.strings.should_update_firmware
-import org.meshtastic.core.strings.traceroute
-import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
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 6b2873d5b..7f9c74d59 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
@@ -64,17 +64,17 @@ import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.connected
+import org.meshtastic.core.resources.connected_device
+import org.meshtastic.core.resources.connected_sleeping
+import org.meshtastic.core.resources.connecting
+import org.meshtastic.core.resources.connections
+import org.meshtastic.core.resources.must_set_region
+import org.meshtastic.core.resources.no_device_selected
+import org.meshtastic.core.resources.not_connected
+import org.meshtastic.core.resources.set_your_region
import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.connected
-import org.meshtastic.core.strings.connected_device
-import org.meshtastic.core.strings.connected_sleeping
-import org.meshtastic.core.strings.connecting
-import org.meshtastic.core.strings.connections
-import org.meshtastic.core.strings.must_set_region
-import org.meshtastic.core.strings.no_device_selected
-import org.meshtastic.core.strings.not_connected
-import org.meshtastic.core.strings.set_your_region
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.TitledCard
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt
index f694a3bf8..131eb33e8 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt
@@ -18,16 +18,13 @@ package com.geeksville.mesh.ui.connections
import android.app.Application
import android.content.Context
-import android.hardware.usb.UsbManager
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
+import com.geeksville.mesh.domain.usecase.GetDiscoveredDevicesUseCase
import com.geeksville.mesh.model.DeviceListEntry
-import com.geeksville.mesh.model.getMeshtasticShortName
-import com.geeksville.mesh.repository.network.NetworkRepository
-import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.usb.UsbRepository
import com.geeksville.mesh.service.MeshService
@@ -36,25 +33,18 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
-import org.jetbrains.compose.resources.getString
import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.database.DatabaseManager
-import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.core.strings.Res
-import org.meshtastic.core.strings.meshtastic
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
-import java.util.Locale
import javax.inject.Inject
@HiltViewModel
@@ -66,12 +56,9 @@ constructor(
private val serviceRepository: ServiceRepository,
private val bluetoothRepository: BluetoothRepository,
private val usbRepository: UsbRepository,
- private val usbManagerLazy: dagger.Lazy,
- private val networkRepository: NetworkRepository,
private val radioInterfaceService: RadioInterfaceService,
private val recentAddressesDataSource: RecentAddressesDataSource,
- private val nodeRepository: NodeRepository,
- private val databaseManager: DatabaseManager,
+ private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
) : ViewModel() {
private val context: Context
get() = application.applicationContext
@@ -81,142 +68,32 @@ constructor(
private val _errorText = MutableStateFlow(null)
val errorText: StateFlow = _errorText.asStateFlow()
- private val nodeDb: StateFlow
Seleccionados
Introduzca su región
Respuesta
- Tu nodo enviará periódicamente un paquete de posición sin cifrar en el mapa al servidor MQTT configurado. Esto incluye id, nombre largo y corto, ubicación aproximada, modelo de hardware, rol, versión del firmware, región LoRa, pre-ajuste del módem y nombre del canal principal.
- Consentimiento para compartir información del nodo sin cifrar por MQTT
- Al habilitar esta función, reconoces y das tu consentimiento expreso para la transmisión de la ubicación geográfica en tiempo real de tu dispositivo a través del protocolo MQTT sin ser esta cifrada.
+ Tu nodo enviará periódicamente un paquete sin encriptar de posición en el mapa al servidor MQTT configurado. Esto incluye id, nombre largo y corto, ubicación aproximada, modelo de hardware, rol, versión del firmware, región LoRa, pre-ajuste del módem y nombre del canal principal.
+ Consentimiento para compartir información del nodo sin encriptar por MQTT
+ Al habilitar esta función, reconoces y das tu consentimiento expreso para la transmisión de la ubicación geográfica en tiempo real de tu dispositivo a través del protocolo MQTT sin ser esta encriptada.
Estos datos de ubicación pueden ser utilizados para fines como aparecer en un mapa en directo, rastrear el dispositivo y funciones de telemetría relacionadas.
He leído y entiendo lo anterior. Doy mi consentimiento voluntario para la transmisión no cifrada de los datos de mi nodo a través de MQTT.
Estoy de acuerdo.
@@ -859,8 +861,8 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m
Activar/Desactivar el módulo de telemetría del dispositivo para enviar métricas a la malla. Estos son valores nominales. Las mallas congestionadas escalarán automáticamente a intervalos más largos basados en el número de nodos en línea. Mallas con menos de nodos escalarán a intervalos más rápidos.
Cualquiera
1 hora
- 8 horas
- 24 horas
+ 8 Horas
+ 24 Horas
48 Horas
Filtrar por tiempo de la última escucha: %1$s
%1$d dBm
@@ -879,10 +881,13 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m
Dispositivo: %1$s
Actualmente instalado: %1$s
Estable
+ Descargando firmware... %1$d%%
Volver a intentar
¡Actualización exitosa!
Hecho
Iniciando DFU...
+ Actualizando... %1$s
+ Desconectando...
Modelo de hardware desconocido: %1$d
No hay dispositivos conectados
Actualización fallida
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index 9015d83ad..a888e723a 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -370,7 +370,7 @@
Non-concordance de clé publique
La clé publique ne correspond pas à la clé enregistrée. Vous pouvez supprimer le nœud et le laisser à nouveau échanger les clés, mais cela peut indiquer un problème de sécurité plus grave. Contactez l'utilisateur à travers un autre canal de confiance, pour déterminer si le changement de clé est dû à une réinitialisation d'usine ou à une autre action intentionnelle.
Infos utilisateur
- Notifications de nouveaux nœuds
+ Notifikasyon nouvo nœud
Plus de détails
SNR
Signal-to-Noise Ratio, une mesure utilisée dans les communications pour quantifier le niveau du signal par rapport au niveau du bruit de fond. Dans les systèmes Meshtastic et autres systèmes sans fil, un SNR plus élevé indique un signal plus clair qui peut améliorer la fiabilité et la qualité de la transmission de données.
diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
index be8d55e46..f29bf910a 100644
--- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
@@ -158,6 +158,7 @@
Crveno
Regija
Udaljenost
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
index 894f38ed3..5aa6d4834 100644
--- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
@@ -334,6 +334,8 @@
Jelenleg:
Mindig némítva
Nincs némítva
+ Némítva ennyi ideig: %1$d nap, %2$.1f óra
+ Némítva: %1$.1f óra
Némítás állapota
Csere
WiFi QR kód szkennelése
@@ -916,9 +918,11 @@
Rendszerbeállítások
Nem állnak rendelkezésre statisztikák
Analitikai adatokat gyűjtünk az Android alkalmazás fejlesztésének segítésére (köszönjük). Anonimizált információkat kapunk a felhasználói viselkedésről, beleértve a hibajelentéseket, a használt képernyőket stb.
+ Analitikai platformok:
További információért lásd az adatvédelmi irányelveinket.
Nincs beállítva – 0
+ Leválasztás…...
A frissítés sikertelen
Nincs beállítva
diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml
index 8b1831776..869922b31 100644
--- a/core/resources/src/commonMain/composeResources/values-it/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml
@@ -305,12 +305,15 @@
Elimina
Questo nodo verrà rimosso dalla tua lista fino a quando il tuo nodo non riceverà di nuovo dei dati.
Disattiva notifiche
+ 1 ora
8 ore
1 settimana
Sempre
Attualmente:
Sempre mutato
Non mutato
+ Mutato per %1$d giorni, %2$.1f ore
+ Mutato per %1$.1f ore
Sostituisci
Scansiona codice QR WiFi
Formato codice QR delle Credenziali WiFi non valido
@@ -880,6 +883,7 @@
Impostazioni di Sistema
Statistiche Non Disponibili
I dati di utilizzo sono raccolti per aiutarci a migliorare l'applicazione Android (grazie), riceveremo informazioni anonimizzate sul comportamento dell'utente. Queste includono rapporti di arresti anomali, schermi utilizzate nell'app, ecc.
+ Piattaforme di analytics:
Per ulteriori informazioni, consulta la nostra informativa sulla privacy.
Disattiva - 0
Ritrasmesso da: %1$s
@@ -897,11 +901,14 @@
Stabile
Alfa
Nota: Questa procedura scollegherà temporaneamente il dispositivo durante l'aggiornamento.
+ Scaricamento in corso del firmware... %1$d%%
Errore: %1$s
Riprova
Aggiornamento Riuscito!
Fatto
Avvio modalità DFU...
+ Aggiornamento in corso... %1$s
+ Disconnessione in corso...
Modello hardware sconosciuto: %1$d
Il dispositivo connesso non è un dispositivo BLE valido oppure l'indirizzo è sconosciuto (%1$s).
Nessun dispositivo connesso
diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
index 804554ba3..246a0cdcd 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -64,6 +64,7 @@
デバイスを見つけやすくするために、デバイス自身の位置情報をメッセージ形式で定期的にデフォルトのチャンネルにブロードキャストします。
TAK PLIの自動ブロードキャストを有効にし、ルーチンブロードキャストを削減します。
周辺クラスターの通信範囲を拡大させるインフラストラクチャノード。他のすべてのノードが通信し終わった後で、必ずパケットを1回だけ再ブロードキャストする。ノードリストに表示される。
+ すべて
受信メッセージが、参加しているプライベートチャンネル上のもの、または同じLoRaパラメータを持つ別のメッシュからのものであれば再ブロードキャストします。
ALLと同じ動作ですが、パケットのデコードをスキップして単純に再ブロードキャストします。 リピーターロールでのみ使用できます。他のロールに設定すると、ALLの動作になります。
開いている外部メッシュや復号できないメッシュからのメッセージを無視。 ノードのローカルプライマリー/セカンダリーチャンネルでのみメッセージを再ブロードキャスト。
@@ -76,6 +77,7 @@
近隣ノード情報(NeighborInfo)をMQTTやPhoneAPIへ送信することに加えて、LoRa無線経由でも送信すべきかどうかを設定します。デフォルトの名前とキーが設定されたチャンネルでは利用できません。
GPIO
+ デバッグ
チャンネル名
QRコード
ユーザー名不明
@@ -99,8 +101,10 @@
接続済み: %1$s オンライン
IPアドレス
ポート:
+ 接続済
Meshtasticデバイスに接続しました
(%1$s)
+ 接続中
接続されていません
接続しましたが、Meshtasticデバイスはスリープ状態です。
アプリを更新して下さい。
@@ -192,6 +196,7 @@
削除
このノードから再びデータを受信するまで、このノードはリストに表示されなくなります。
通知をミュート
+ 1時間
8時間
1週間
常時
@@ -519,11 +524,13 @@
同意します。
ファームウェアの更新を推奨します。
最新の修正や機能をご利用いただくために、お使いのノードのファームウェアをアップデートしてください。\n\n最新の安定ファームウェアバージョン: %1$s
+ 切断
Meshtastic
+ 中止
メッセージ
設定
"[リモート] %1$s"
@@ -537,6 +544,7 @@
リンクを処理できるアプリケーションがありません。
システム設定
+ 切断中...
更新失敗
削除
diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
index 251f273ae..99173ea7a 100644
--- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
@@ -560,6 +560,7 @@
24 시간
48 시간
+ 연결 끊는 중...
업데이트 실패
해제
diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
index 2ed6cad22..45e30e386 100644
--- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
@@ -239,7 +239,6 @@
Žinutė
-
8 Valandos
24 Valandos
48 Valandos
diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml
index 96f0b4318..2e5e6e16b 100644
--- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml
@@ -429,6 +429,7 @@
24 Uur
48 Uur
+ Verbinding verbreken...
Bijwerken mislukt
Terugzetten
diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
index d11e98841..c57703502 100644
--- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
@@ -390,7 +390,7 @@
- 1 skok
- %d skoki
- - %d skoki
+ - %d skoków
- %d skoków
Skoki do: %1$d. Skoki od: %2$d
@@ -630,6 +630,7 @@
Import
Informacje o sąsiadach (2.7.15+)
Żądanie telemetrii
+ Metryka urządzenia
Metryki środowiskowe
Metryki jakości powietrza
Metryki zasilania
@@ -638,6 +639,7 @@
Metadane
Oprogramowanie
Użyj formatu 12-godzinnego
+ Statystyki hosta
Połączenie
Mapa Sieci
Czaty
@@ -706,7 +708,6 @@
Pomiń
ustawienia
Alerty krytyczne
-
Dalej
Przyznaj uprawnienia
Łączenie z urządzeniem
diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
index 522232c08..70b35e272 100644
--- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
@@ -547,6 +547,7 @@
24 Horas
48 Horas
+ A desligar...
Atualização falhou
Não Definido
diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
index 6ad5f2b43..267543232 100644
--- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
@@ -590,8 +590,13 @@
Criptare activată
Ieșire JSON activată
TLS activat
+ Rețea
+ Activat
Configurație Paxcounter
Paxcounter activat
+ Poziție
+ Securitate
+ Expirat
Nume lung
Nume scurt
@@ -605,8 +610,13 @@
Radiație
URL
+ Valori dispozitiv
+ Indicatori de mediu
+ Valori putere
Arată repere
(%1$d online / %2$d afișate / %3$d în total)
+ Meshtastic
+ Avansate
@@ -614,6 +624,10 @@
Mesaj
Mesajele provenite de la o un gateway public de internet sunt redirecționate către rețeaua locală. Datorită politicii de zero salturi, traficul provenit de la serverul MQTT implicit nu se va propaga mai departe de acest dispozitiv.
Activează/dezactivează modulul de telemetrie al dispozitivului pentru a trimite metrici către rețeaua mesh. Acestea sunt valori nominale. Rețelele mesh congestionate se vor scala automat la intervale mai lungi, în funcție de numărul de noduri online.
+ O oră
+ 8 Ore
+ 24 Ore
+ 48 Ore
Actualizare eșuată
Nesetat
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index 0d27170bd..c830d773b 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -217,12 +217,12 @@
- %1$d час
- %1$d часа
- %1$d часов
- - %1$d часы
+ - %1$d часа
- %1$d день
- - %1$d дня
- - %1$d дней
+ - %1$d дней
+ - %1$d дня
- %1$d дни
Фильтры
@@ -252,7 +252,7 @@
Служебные уведомления
Требуется обновление прошивки.
Прошивка радиостанции слишком стара для взаимодействия с данным приложением. Чтобы получить больше сведений, посетите наше руководство по установке прошивки.
- ОК
+ Лады
Вы должны задать регион!
Не удалось сменить канал, поскольку радиостанция еще не подключена. Пожалуйста, попробуйте еще раз.
Экспортировать пакеты для проверки дальности
@@ -416,7 +416,7 @@
- %1$d хоп
- %1$d хопа
- - %1$d хопов
+ - %d хопов
- %1$d хопов
Нод к %1$d нод назад от %2$d
@@ -982,6 +982,7 @@
Настройка системы
Статистика недоступна
Аналитика помогает нам улучшить Android приложение (спасибо), мы будем получать анонимизированную информацию о поведении пользователя. В частности: отчеты о сбоях, используемые экраны и пр.
+ Платформы для аналитики:
Дополнительная информация доступна в нашей политике конфиденциальности.
Не задано - 0
Ретранслировано: %1$s
diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
index 0d8191a2f..a85f2e836 100644
--- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
@@ -437,6 +437,7 @@
Primárna
Sekundárna
Nastavenia
+ Meshtastic
diff --git a/core/resources/src/commonMain/composeResources/values-sl/strings.xml b/core/resources/src/commonMain/composeResources/values-sl/strings.xml
index f254fda8c..b238e6898 100644
--- a/core/resources/src/commonMain/composeResources/values-sl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sl/strings.xml
@@ -224,7 +224,7 @@
- 1 skok
- %dskoka
- - %dskoka
+ - %dskoki
- %dskoki
Skokov k %1$d Skokov nazaj %2$d
diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
index e81848aac..aa81c2e71 100644
--- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
@@ -440,6 +440,7 @@
24 Сати
48 Сати
+ Грешка: %1$s
Нема повезаних уређаја
Ažuriranje nije uspelo
Белешке о издању
diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
index 81ecd2e86..fed146525 100644
--- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
@@ -440,6 +440,7 @@
24 Сати
48 Сати
+ Грешка: %1$s
Нема повезаних уређаја
Ажурирање неуспело
Белешке о издању
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index 4b9d29997..d79b8af5e 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -52,7 +52,7 @@
Inget gränssnitt
Maximalt antal sändningar nådd
Ingen kanal
- Paket är för stort
+ Paket för stort
Inget svar
Misslyckad
Gränsen för intermittensfaktor uppnådd
@@ -244,7 +244,7 @@
Waypoint-aviseringar
Larmmeddelanden
Uppdatering av fast programvara krävs.
- Radiomodulens fasta programvara är för gammal för att prata med denna applikation. För mer information om detta se vår installationsguide för fast programvara.
+ Radiomodulens firmware är för gammal för att prata med denna applikation. För mer information om detta se vår installationsguide för Firmware.
Okej
Du måste ställa in en region!
Det gick inte att byta kanal, eftersom radiomodulen ännu inte är ansluten. Försök igen.
@@ -302,7 +302,7 @@
Återställ till standardinställningar
Bluetooth är inaktiverat. Aktivera den i inställningarna för enheten.
Öppna inställningar
- Fast programvaruversion: %1$s
+ Fast programversion: %1$s
Meshtastic behöver \"Närliggande enheter\"-behörigheter aktiverade för att hitta och ansluta till enheter via Bluetooth. Du kan inaktivera när den inte används.
Direktmeddelande
Nollställ NodeDB
@@ -736,7 +736,7 @@
Begär värdens värden
Metadata
Åtgärder
- Fast programvara
+ Firmware
Använd 12-timmarsformat
Visar tiden i 12-timmarsformat när denna är aktiverad.
Värdstatistik
@@ -754,7 +754,7 @@
Vald
Ange din region
Svara
- Din nod kommer periodiskt skicka ett okrypterat paket till den konfigurerade MQTT-servern som inkluderar id, kort och långt namn, ungefärlig plats, hårdvarumodell, enhetsroll, mjukvaruversion, LoRa-region, modeminställning och den primära kanalens namn.
+ Din nod kommer periodiskt skicka ett okrypterat paket till den konfigurerade MQTT-servern som inkluderar id, kort och långt namn, ungefärlig plats, hardvarumodell, enhetsroll, mjukvaru-version, LoRa-region, modeminställning och den primära kanalens namn.
Samtycke för att dela okrypterad noddata via MQTT
Genom att aktivera den här funktionen bekräftar och samtycker du till överföringen av enhetens geografiska plats i realtid över MQTT-protokollet utan kryptering. Denna platsdata kan användas för ändamål som live-kartrapportering, enhetsspårning och relaterade telemetrifunktioner.
Jag har läst och förstått ovanstående. Jag samtycker till okrypterad överföring av mina noddata via MQTT
@@ -932,6 +932,7 @@
Systeminställningar
Ingen tillgänglig statistik
Mätdata samlas in för att hjälpa oss att förbättra Android-appen (tack), vi kommer att få anonymiserad information om användarnas beteende. Detta inkluderar kraschrapporter, skärmar som används i appen etc.
+ Analysplattformar:
För mer information, se vår integritetspolicy.
Odefinierad - 0
Vidaresänt av: %1$s
diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
index 15f072a5f..e5ec12c86 100644
--- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
@@ -565,6 +565,7 @@
24 Saat
48 Saat
+ Bağlantı Kesiliyor...
Güncelleme başarısız
Ayarlanmamış
diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
index 4d699fa80..97c86ca2f 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -145,7 +145,7 @@
Поточні з'єднання:
Wi-Fi IP:
IP Ethernet:
- Підключення...
+ Під’єднання
Не підключено
Підключено до радіомодуля, але він в режимі сну
Потрібне оновлення програми
@@ -162,13 +162,13 @@
- %1$d година
- %1$d години
- - %1$d годин
+ - %1$d години
- %1$d годин
- %1$d день
- %1$d дні
- - %1$d днів
+ - %1$d дні
- %1$d днів
Фільтри
@@ -687,6 +687,7 @@
Системі налаштування
Статистика відсутня
Аналітика збирається для того, щоб допомогти нам покращити додаток для Android (дякуємо), ми будемо отримувати анонімну інформацію про поведінку користувачів. Це включає звіти про збої, екрани, що використовуються в програмі й тому подібне.
+ Аналітичні платформи:
Для додаткової інформації, перегляньте нашу політику конфіденційності.
%1$s зазвичай постачається із завантажувачем, який не підтримує оновлення OTA. Вам може знадобитися завантажувач з можливістю оновлень OTA через USB перед прошиванням OTA.
Докладніше
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index 0a4691d3a..e2eee7314 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -385,11 +385,11 @@
RSSI
接收信號強度指示(RSSI)用於測量天線所接收到信號的功率強度。 RSSI 值越高通常代表連線越強且穩定。
(室內空氣品質) 相對尺度 IAQ 值,由 Bosch BME680 測量。值範圍 0–500。
- 裝置指標
+ 裝置計量資料
節點地圖
位置
最後位置更新
- 環境指標
+ 環境計量資料
管理
遠端管理
不良
@@ -432,7 +432,7 @@
從我的最愛中移除
是否將“%1$s”添加為我的最愛節點?
是否從我的最愛節點中删除“%1$s”?
- 電力指標
+ 電源計量資料
頻道1
頻道2
頻道3
@@ -810,7 +810,6 @@
模組已解鎖
模組已解鎖
遠端
-
回應
中斷連線
找不到網路裝置。
@@ -967,6 +966,7 @@
系統設定
沒有可用的統計資料
我們會收集分析數據以協助改善 Android 應用程式(感謝您的支持),我們將收到匿名化的使用者行為資訊,包括當機報告、應用程式使用畫面等。
+ 分析平台:
欲了解更多資訊,請查閱我們的隱私權政策。
預設值 - 0
經由:%1$s
@@ -1094,7 +1094,6 @@
正規表示式
完整字詞比對
已篩選 %1$d 則
-
隱藏已篩選 %1$d 則
已篩選
啟用篩選
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 003/474] 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" }
From b29a18c31ef0f624ccee3b0cfeab7031509725c6 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 24 Feb 2026 07:45:27 -0600
Subject: [PATCH 004/474] chore(deps): update com.squareup.okio:okio to v3.16.4
(#4633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3514770fa..9341f4201 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -50,7 +50,7 @@ detekt = "1.23.8"
dokka = "2.2.0-Beta"
devtools-ksp = "2.3.6"
markdownRenderer = "0.39.2"
-okio = "3.10.2"
+okio = "3.16.4"
osmdroid-android = "6.1.20"
spotless = "8.2.1"
wire = "6.0.0-alpha02"
From 3e4f33854b06d7838c2e35d56c39f88b0c4a7912 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 24 Feb 2026 08:03:04 -0600
Subject: [PATCH 005/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4629)
---
app/src/main/assets/device_hardware.json | 2 +-
app/src/main/assets/firmware_releases.json | 6 ------
core/model/README.md | 4 +---
feature/messaging/README.md | 2 ++
4 files changed, 4 insertions(+), 10 deletions(-)
diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json
index 0699ff16b..838eac006 100644
--- a/app/src/main/assets/device_hardware.json
+++ b/app/src/main/assets/device_hardware.json
@@ -1273,7 +1273,7 @@
"hwModelSlug": "WISMESH_TAP_V2",
"platformioTarget": "rak_wismesh_tap_v2",
"architecture": "esp32-s3",
- "activelySupported": false,
+ "activelySupported": true,
"supportLevel": 1,
"displayName": "RAK WisMesh Tap V2",
"tags": [
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 65e5e1f6a..c2f2de895 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -188,12 +188,6 @@
]
},
"pullRequests": [
- {
- "id": "9710",
- "title": "Add T114 indexed display system with color palette and overlay queue",
- "page_url": "https://github.com/meshtastic/firmware/pull/9710",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
{
"id": "9709",
"title": "platform: nrf52: Fix typo in BLEDfuSecure filename",
diff --git a/core/model/README.md b/core/model/README.md
index 2c5f91338..9a3eab108 100644
--- a/core/model/README.md
+++ b/core/model/README.md
@@ -31,9 +31,7 @@ implementation(projects.core.model)
```mermaid
graph TB
- :core:model[model]:::android-library
- :core:model --> :core:proto
- :core:model --> :core:common
+ :core:model[model]:::kmp-library
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
diff --git a/feature/messaging/README.md b/feature/messaging/README.md
index 2fc164914..59d8736bb 100644
--- a/feature/messaging/README.md
+++ b/feature/messaging/README.md
@@ -26,9 +26,11 @@ A security-focused utility that detects and transforms homoglyphs (visually simi
```mermaid
graph TB
:feature:messaging[messaging]:::android-feature
+ :feature:messaging -.-> :core:analytics
:feature:messaging -.-> :core:data
:feature:messaging -.-> :core:database
:feature:messaging -.-> :core:model
+ :feature:messaging -.-> :core:navigation
:feature:messaging -.-> :core:prefs
:feature:messaging -.-> :core:proto
:feature:messaging -.-> :core:service
From b5e5c9cbe0a98f521bd750349d8133d1d891ef75 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 24 Feb 2026 12:34:38 -0600
Subject: [PATCH 006/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4634)
---
app/src/main/assets/device_hardware.json | 2 +-
.../commonMain/composeResources/values-de/strings.xml | 11 +++++++++++
.../commonMain/composeResources/values-fi/strings.xml | 11 +++++++++++
3 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json
index 838eac006..0699ff16b 100644
--- a/app/src/main/assets/device_hardware.json
+++ b/app/src/main/assets/device_hardware.json
@@ -1273,7 +1273,7 @@
"hwModelSlug": "WISMESH_TAP_V2",
"platformioTarget": "rak_wismesh_tap_v2",
"architecture": "esp32-s3",
- "activelySupported": true,
+ "activelySupported": false,
"supportLevel": 1,
"displayName": "RAK WisMesh Tap V2",
"tags": [
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index e2ceec00c..88ae5fbc5 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -623,6 +623,7 @@
WiFi aktiviert
SSID
PSK
+ Dokument abrufen
Ethernet Einstellungen
Ethernet aktiviert
NTP Server
@@ -1142,4 +1143,14 @@
Berechtigung gewährt
Berechtigung verweigert
Auswahl Kartenstil
+ Akku: %1$d%%
+ Knoten: %1$d online / %2$d gesamt
+ Laufzeit: %1$s
+ Kanalauslastung: %1$.2f%% | Sendezeit: %2$.2f%%
+ Datenverkehr: TX %1$d / RX %2$d (Duplikate: %3$d)
+ Weiterleitungen: %1$d (Abgebrochen: %2$d)
+ Diagnose %1$s
+ Rauschen %1$d dBm
+ Fehlerhaft %1$d
+ Verworfen %1$d
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index ebb8440c5..67de5647c 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -623,6 +623,7 @@
WiFi käytössä
SSID
PSK
+ Hae asiakirja
Verkon asetukset
Ethernet käytössä
NTP palvelin
@@ -1143,4 +1144,14 @@
Lupa myönnetty
Lupa evätty
Karttatyylin valinta
+ Akku: %1$d%%
+ Laitteet: %1$d verkossa / %2$d yhteensä
+ Käyttöaika: %1$s
+ Kanavan käytöaste: %1$.2f%% | Lähestysajan käyttöaste: %2$.2f%%
+ Liikenne: Lähetetyt %1$d / Vastaanotetut %2$d (Kaksoiskappaleet: %3$d)
+ Välitetyt: %1$d (Peruutetut: %2$d)
+ Vianmääritys: %1$s
+ Kohinataso %1$d dBm
+ Huonot %1$d
+ Hylätyt paketit %1$d
From 8d3568eee52f52c857ff24a219e2dbcaaea30d4e Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 24 Feb 2026 22:12:23 -0600
Subject: [PATCH 007/474] chore(deps): update coil to v3.4.0 (#4640)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9341f4201..74ef241d5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -44,7 +44,7 @@ ktor = "3.4.0"
# Other
aboutlibraries = "13.2.1"
-coil = "3.3.0"
+coil = "3.4.0"
dd-sdk-android = "3.6.0"
detekt = "1.23.8"
dokka = "2.2.0-Beta"
From 754b9c57690d3a260c9e81d34838f23ed29d1457 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 24 Feb 2026 22:12:35 -0600
Subject: [PATCH 008/474] chore(deps): update google maps compose to v8.2.0
(#4641)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 74ef241d5..ed5658f8c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -31,7 +31,7 @@ compose-multiplatform = "1.10.1"
# Google
hilt = "2.59.2"
-maps-compose = "8.1.0"
+maps-compose = "8.2.0"
# ML Kit
mlkit-barcode-scanning = "17.3.0"
From bd9e96e5ea18ede1a9d0c0560e0b8314908b7bfc Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 24 Feb 2026 22:13:00 -0600
Subject: [PATCH 009/474] chore(deps): update core/proto/src/main/proto digest
to f7f7c8d (#4638)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
core/proto/src/main/proto | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/proto/src/main/proto b/core/proto/src/main/proto
index 93bee530c..f7f7c8d2e 160000
--- a/core/proto/src/main/proto
+++ b/core/proto/src/main/proto
@@ -1 +1 @@
-Subproject commit 93bee530c302594883dc28badad4c564b7274837
+Subproject commit f7f7c8d2e4bf27013efe833d322a2306f2514c39
From 2803eae63c1351d28ad103883d7785a4bd68d953 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 24 Feb 2026 22:13:11 -0600
Subject: [PATCH 010/474] chore(deps): update vico to v3.0.1 (#4639)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ed5658f8c..fb9c5beeb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -54,7 +54,7 @@ okio = "3.16.4"
osmdroid-android = "6.1.20"
spotless = "8.2.1"
wire = "6.0.0-alpha02"
-vico = "3.0.0"
+vico = "3.0.1"
dependency-guard = "0.5.0"
nordic-ble = "2.0.0-alpha15"
nordic-common = "2.9.1"
From 6b94b12258c63c81f50dae96d25d40b6cf203384 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 24 Feb 2026 22:13:25 -0600
Subject: [PATCH 011/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4637)
---
.../commonMain/composeResources/values-et/strings.xml | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index 4cd436427..89209b701 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -623,6 +623,7 @@
Wifi lubatud
SSID
PSK
+ Lae dokument
Etherneti valikud
Ethernet lubatud
NTP server
@@ -1142,4 +1143,14 @@
Luba antud
Luba mitte antud
Kaardi stiilis valik
+ Aku: %1$d%%
+ Sõlmed: %1$d võrgus / %2$d kokku
+ Töös: %1$s
+ ChUtil: %1$.2f%% | AirTX: %2$.2f%%
+ Ühendus: TX %1$d / RX %2$d (Dupes: %3$d)
+ Vahendatud: %1$d (Tühistatud: %2$d)
+ Diagnostika: %1$s
+ Müra %1$d dBm
+ Paha %1$d
+ Kukkunud %1$d
From ce2d4c6b9c8137c6be6a952288250504d53abd18 Mon Sep 17 00:00:00 2001
From: Ken Piper
Date: Wed, 25 Feb 2026 07:14:22 -0600
Subject: [PATCH 012/474] Add per-message transport method icons for new
message format (#4643)
---
.../feature/messaging/component/MessageItem.kt | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
index 79f7c8ba6..115e3633e 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
@@ -239,11 +239,6 @@ internal fun MessageItem(
maxLines = 1,
style = MaterialTheme.typography.labelMedium,
)
- TransportIcon(
- transport = message.transportMechanism,
- viaMqtt = message.viaMqtt,
- modifier = Modifier.size(16.dp),
- )
}
}
Surface(
@@ -317,9 +312,14 @@ internal fun MessageItem(
)
}
}
+ TransportIcon(
+ transport = message.transportMechanism,
+ viaMqtt = message.viaMqtt,
+ modifier = Modifier.size(16.dp).padding(start = 4.dp),
+ )
}
if (containsBel) {
- Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp))
+ Text(text = "\uD83D\uDD14", modifier = Modifier.padding(start = 4.dp))
}
if (message.filtered) {
Text(
From 692ad78c8069830815de1ccdb13c46fcbfcdf246 Mon Sep 17 00:00:00 2001
From: Jade <42078529+ujade@users.noreply.github.com>
Date: Wed, 25 Feb 2026 10:31:52 -0700
Subject: [PATCH 013/474] Align FDroid MapView constructor with Google version
(Issue #4576) (#4630)
Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt | 2 ++
1 file changed, 2 insertions(+)
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 0c3882821..7b968b53c 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
@@ -213,6 +213,8 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int)
fun MapView(
mapViewModel: MapViewModel = hiltViewModel(),
navigateToNodeDetails: (Int) -> Unit,
+ focusedNodeNum: Int? = null,
+ nodeTracks: List? = null,
tracerouteOverlay: TracerouteOverlay? = null,
tracerouteNodePositions: Map = emptyMap(),
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
From 9970d3152091b8bcc7eaff23cb20c9c817d9027a Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 25 Feb 2026 13:39:00 -0600
Subject: [PATCH 014/474] feat(widget): Add Local Stats glance widget (#4642)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/build.gradle.kts | 5 +
app/src/main/AndroidManifest.xml | 11 +
.../geeksville/mesh/MeshUtilApplication.kt | 48 ++
.../mesh/service/MeshConnectionManager.kt | 19 +
.../service/MeshServiceNotificationsImpl.kt | 31 +-
.../mesh/widget/LocalStatsWidget.kt | 412 ++++++++++++++++++
.../mesh/widget/LocalStatsWidgetReceiver.kt | 26 ++
.../mesh/widget/LocalStatsWidgetState.kt | 251 +++++++++++
.../mesh/widget/RefreshLocalStatsAction.kt | 52 +++
app/src/main/res/drawable/ic_refresh.xml | 27 ++
.../res/layout/widget_local_stats_preview.xml | 42 ++
.../main/res/xml/local_stats_widget_info.xml | 25 ++
.../mesh/service/MeshConnectionManagerTest.kt | 12 +
.../LocalStatsWidgetStateProviderTest.kt | 119 +++++
.../core/data/repository/NodeRepository.kt | 18 +-
.../data/repository/NodeRepositoryTest.kt | 11 +-
.../core/datastore/LocalStatsDataSource.kt | 48 ++
.../core/datastore/di/DataStoreModule.kt | 14 +
.../serializer/LocalStatsSerializer.kt | 40 ++
.../meshtastic/core/resources/ContextExt.kt | 41 +-
.../composeResources/values/strings.xml | 9 +-
.../ui/contact/AdaptiveContactsScreen.kt | 14 +-
gradle/libs.versions.toml | 5 +
23 files changed, 1256 insertions(+), 24 deletions(-)
create mode 100644 app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetReceiver.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
create mode 100644 app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
create mode 100644 app/src/main/res/drawable/ic_refresh.xml
create mode 100644 app/src/main/res/layout/widget_local_stats_preview.xml
create mode 100644 app/src/main/res/xml/local_stats_widget_info.xml
create mode 100644 app/src/test/java/com/geeksville/mesh/widget/LocalStatsWidgetStateProviderTest.kt
create mode 100644 core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalStatsDataSource.kt
create mode 100644 core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1743e37bc..2a740864b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -243,6 +243,9 @@ dependencies {
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.ui.text)
+ implementation(libs.androidx.glance.appwidget)
+ implementation(libs.androidx.glance.appwidget.preview)
+ implementation(libs.androidx.glance.material3)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
@@ -268,6 +271,7 @@ dependencies {
implementation(libs.nordic.common.ui)
debugImplementation(libs.androidx.compose.ui.test.manifest)
+ debugImplementation(libs.androidx.glance.preview)
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
@@ -293,6 +297,7 @@ dependencies {
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit)
+ testImplementation(libs.androidx.glance.appwidget)
}
aboutLibraries {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 83a745521..64d43a759 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -241,6 +241,17 @@
+
+
+
+
+
+
+
= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ applicationScope.launch {
+ suspend fun pushPreview() {
+ try {
+ Logger.i { "Pushing generated widget preview..." }
+ val result =
+ GlanceAppWidgetManager(this@MeshUtilApplication)
+ .setWidgetPreviews(
+ LocalStatsWidgetReceiver::class,
+ intSetOf(AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN),
+ )
+ Logger.i { "setWidgetPreviews result: $result" }
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "Failed to set widget preview" }
+ }
+ }
+
+ pushPreview()
+
+ val entryPoint =
+ EntryPointAccessors.fromApplication(
+ this@MeshUtilApplication,
+ com.geeksville.mesh.widget.LocalStatsWidget.LocalStatsWidgetEntryPoint::class.java,
+ )
+ try {
+ // Wait for real data for up to 30 seconds before pushing an updated preview
+ withTimeout(30.seconds) {
+ entryPoint.widgetStateProvider().state.first { it.showContent && it.nodeShortName != null }
+ }
+
+ Logger.i { "Real node data acquired. Pushing updated widget preview." }
+ pushPreview()
+ } catch (e: TimeoutCancellationException) {
+ Logger.i(e) { "Timed out waiting for real node data for widget preview." }
+ }
+ }
+ }
+
// Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
applicationScope.launch { entryPoint.databaseManager().init(entryPoint.meshPrefs().deviceAddress) }
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 ec3f2bfa3..bd777c538 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
@@ -17,17 +17,23 @@
package com.geeksville.mesh.service
import android.app.Notification
+import android.content.Context
+import androidx.glance.appwidget.updateAll
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.RadioInterfaceService
+import com.geeksville.mesh.widget.LocalStatsWidget
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
@@ -61,6 +67,7 @@ import kotlin.time.DurationUnit
class MeshConnectionManager
@Inject
constructor(
+ @ApplicationContext private val context: Context,
private val radioInterfaceService: RadioInterfaceService,
private val connectionStateHolder: ConnectionStateHandler,
private val serviceBroadcasts: MeshServiceBroadcasts,
@@ -82,6 +89,7 @@ constructor(
private var handshakeTimeout: Job? = null
private var connectTimeMsec = 0L
+ @OptIn(FlowPreview::class)
fun start(scope: CoroutineScope) {
this.scope = scope
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
@@ -89,6 +97,16 @@ constructor(
// Ensure notification title and content stay in sync with state changes
connectionStateHolder.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
+ // Kickstart the widget composition. The widget internally uses collectAsState()
+ // and its own sampled StateFlow to drive updates automatically without excessive IPC and recreation.
+ scope.launch {
+ try {
+ LocalStatsWidget().updateAll(context)
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "Failed to kickstart LocalStatsWidget" }
+ }
+ }
+
nodeRepository.myNodeInfo
.onEach { myNodeEntity ->
locationRequestsJob?.cancel()
@@ -286,6 +304,7 @@ constructor(
}
fun updateTelemetry(telemetry: Telemetry) {
+ telemetry.local_stats?.let { nodeRepository.updateLocalStats(it) }
updateStatusNotification(telemetry)
}
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 67447d628..6128caaf6 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
@@ -61,6 +61,8 @@ 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_heap
+import org.meshtastic.core.resources.local_stats_heap_value
import org.meshtastic.core.resources.local_stats_nodes
import org.meshtastic.core.resources.local_stats_noise
import org.meshtastic.core.resources.local_stats_relays
@@ -81,6 +83,7 @@ import org.meshtastic.core.resources.meshtastic_service_notifications
import org.meshtastic.core.resources.meshtastic_waypoints_notifications
import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.core.resources.no_local_stats
+import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.you
import org.meshtastic.core.service.MeshServiceNotifications
@@ -312,7 +315,10 @@ constructor(
cachedDeviceMetrics = entity.deviceTelemetry.device_metrics
}
if (cachedLocalStats == null) {
- cachedLocalStats = entity.deviceTelemetry.local_stats
+ // Fallback to DB stats if repository hasn't received any fresh ones yet
+ cachedLocalStats =
+ repo.localStats.value.takeIf { it.uptime_seconds != 0 }
+ ?: entity.deviceTelemetry.local_stats
}
}
}
@@ -855,11 +861,26 @@ constructor(
private fun LocalStats.formatToString(batteryLevel: Int? = null): String {
val parts = mutableListOf()
- batteryLevel?.let { parts.add(BULLET + getString(Res.string.local_stats_battery, it)) }
+ batteryLevel?.let {
+ if (it > MAX_BATTERY_LEVEL) {
+ parts.add(BULLET + getString(Res.string.powered))
+ } else {
+ 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))
+ if (heap_free_bytes > 0 || heap_total_bytes > 0) {
+ parts.add(
+ BULLET +
+ getString(Res.string.local_stats_heap) +
+ ": " +
+ getString(Res.string.local_stats_heap_value, heap_free_bytes, heap_total_bytes),
+ )
+ }
+
// 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))
@@ -887,7 +908,11 @@ constructor(
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))
+ if (channel_utilization != null || air_util_tx != null) {
+ parts.add(
+ BULLET + getString(Res.string.local_stats_utilization, channel_utilization ?: 0f, air_util_tx ?: 0f),
+ )
+ }
return parts.joinToString("\n")
}
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
new file mode 100644
index 000000000..2be3f1878
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
@@ -0,0 +1,412 @@
+/*
+ * 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 com.geeksville.mesh.widget
+
+import android.annotation.SuppressLint
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.GlanceTheme
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.actionStartActivity
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.CircularProgressIndicator
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.LinearProgressIndicator
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionRunCallback
+import androidx.glance.appwidget.components.CircleIconButton
+import androidx.glance.appwidget.components.Scaffold
+import androidx.glance.appwidget.components.TitleBar
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextStyle
+import androidx.glance.unit.ColorProvider
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.air_utilization
+import org.meshtastic.core.resources.battery
+import org.meshtastic.core.resources.channel_utilization
+import org.meshtastic.core.resources.getStringSuspend
+import org.meshtastic.core.resources.meshtastic_app_name
+import org.meshtastic.core.resources.nodes
+import org.meshtastic.core.resources.refresh
+import org.meshtastic.core.resources.updated
+import org.meshtastic.core.resources.uptime
+import org.meshtastic.core.service.ConnectionState
+
+class LocalStatsWidget : GlanceAppWidget() {
+
+ override val sizeMode: SizeMode = SizeMode.Responsive(RESPONSIVE_SIZES)
+ override val previewSizeMode: androidx.glance.appwidget.PreviewSizeMode = SizeMode.Responsive(RESPONSIVE_SIZES)
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface LocalStatsWidgetEntryPoint {
+ fun widgetStateProvider(): LocalStatsWidgetStateProvider
+ }
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val entryPoint =
+ EntryPointAccessors.fromApplication(context.applicationContext, LocalStatsWidgetEntryPoint::class.java)
+ val stateProvider = entryPoint.widgetStateProvider()
+
+ provideContent {
+ val state by stateProvider.state.collectAsState()
+ WidgetContent(state)
+ }
+ }
+
+ override suspend fun providePreview(context: Context, widgetCategory: Int) {
+ val entryPoint =
+ EntryPointAccessors.fromApplication(context.applicationContext, LocalStatsWidgetEntryPoint::class.java)
+ val stateProvider = entryPoint.widgetStateProvider()
+ val currentState = stateProvider.state.value
+
+ val stateToRender =
+ if (currentState.showContent && currentState.nodeShortName != null) {
+ currentState
+ } else {
+ createMockWidgetState()
+ }
+ provideContent { WidgetContent(stateToRender) }
+ }
+
+ @Composable
+ internal fun WidgetContent(state: LocalStatsWidgetUiState) {
+ val context = LocalContext.current
+ CompositionLocalProvider(
+ androidx.compose.ui.platform.LocalContext provides context,
+ LocalConfiguration provides context.resources.configuration,
+ LocalDensity provides Density(context.resources.displayMetrics.density),
+ ) {
+ GlanceTheme {
+ Scaffold(
+ titleBar = {
+ TitleBar(
+ startIcon = ImageProvider(com.geeksville.mesh.R.drawable.app_icon),
+ title = state.appName,
+ actions = {
+ CircleIconButton(
+ imageProvider = ImageProvider(com.geeksville.mesh.R.drawable.ic_refresh),
+ contentDescription = state.refreshLabel,
+ onClick = actionRunCallback(),
+ backgroundColor = null,
+ )
+ },
+ )
+ },
+ modifier =
+ GlanceModifier.fillMaxSize().clickable(actionStartActivity()),
+ ) {
+ if (state.showContent) {
+ FullStatsContent(state)
+ } else {
+ Disconnected(state)
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun FullStatsContent(state: LocalStatsWidgetUiState) {
+ val size = LocalSize.current
+ val isNarrow = size.width < 160.dp
+ val isShort = size.height < 110.dp
+ val isSmall = isNarrow || isShort
+ Column(modifier = GlanceModifier.fillMaxSize()) {
+ // Main Stats Container
+ Column(modifier = GlanceModifier.defaultWeight()) {
+ // Summary Header: Node Chip + Battery
+ Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ state.nodeShortName?.let { name ->
+ state.nodeColors?.let { colors -> NodeChip(shortName = name, colors = colors) }
+ }
+ Spacer(GlanceModifier.width(8.dp))
+ StatRow(
+ label = state.batteryLabel,
+ value = state.batteryValue,
+ progress = state.batteryProgress,
+ isSmall = isSmall,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ }
+
+ Spacer(GlanceModifier.height(2.dp))
+
+ // Utilization Stats
+
+ Row(modifier = GlanceModifier.fillMaxWidth()) {
+ StatRow(
+ label = state.channelUtilizationLabel,
+ value = state.channelUtilizationValue,
+ progress = state.channelUtilizationProgress,
+ isSmall = isSmall,
+ modifier = GlanceModifier.defaultWeight().padding(end = 4.dp),
+ )
+ StatRow(
+ label = state.airUtilizationLabel,
+ value = state.airUtilizationValue,
+ progress = state.airUtilizationProgress,
+ isSmall = isSmall,
+ modifier = GlanceModifier.defaultWeight().padding(start = 4.dp),
+ )
+ }
+
+ // Detailed Traffic/Relay Stats
+ Spacer(GlanceModifier.height(2.dp))
+ Column(modifier = GlanceModifier.fillMaxWidth()) {
+ state.trafficText?.let { StatText(it, isSmall) }
+ state.relayText?.let { StatText(it, isSmall) }
+ state.diagnosticsText?.let { StatText(it, isSmall) }
+ state.heapText?.let {
+ val heapProgress =
+ if (state.heapTotalBytes > 0) {
+ state.heapFreeBytes.toFloat() / state.heapTotalBytes
+ } else {
+ 0f
+ }
+ StatRow(it, state.heapValue, heapProgress, isSmall)
+ }
+ }
+ }
+
+ // Footer (Nodes + Uptime - Pinned to bottom)
+ Footer(state)
+ }
+ }
+
+ @Composable
+ private fun StatText(text: String, isSmall: Boolean) {
+ Text(
+ text = text,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = if (isSmall) 9.sp else 10.sp),
+ modifier = GlanceModifier.fillMaxWidth(),
+ )
+ }
+
+ @Composable
+ private fun Disconnected(state: LocalStatsWidgetUiState) {
+ Column(
+ modifier = GlanceModifier.fillMaxSize(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (state.isConnecting) {
+ CircularProgressIndicator(modifier = GlanceModifier.size(24.dp))
+ } else {
+ Image(
+ provider = ImageProvider(com.geeksville.mesh.R.drawable.app_icon),
+ contentDescription = null,
+ modifier = GlanceModifier.size(32.dp),
+ )
+ }
+ Text(
+ text = state.statusText,
+ style =
+ TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ }
+
+ @Composable
+ private fun Footer(state: LocalStatsWidgetUiState) {
+ Column(modifier = GlanceModifier.fillMaxWidth()) {
+ Row(
+ modifier = GlanceModifier.fillMaxWidth().padding(top = 2.dp, bottom = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.Start) {
+ Text(
+ text = state.nodesLabel,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp),
+ )
+ Text(
+ text = state.nodeCountText,
+ maxLines = 1,
+ style =
+ TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) {
+ Text(
+ text = state.uptimeLabel,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp),
+ )
+ Text(
+ text = state.uptimeText,
+ maxLines = 1,
+ style =
+ TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ }
+ Row(modifier = GlanceModifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ val footerText =
+ if (state.updatedLabel.isNotEmpty()) {
+ "${state.updatedLabel} ${state.updatedText}"
+ } else {
+ state.updatedText
+ }
+ Text(
+ text = footerText,
+ style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 8.sp),
+ modifier = GlanceModifier.padding(bottom = 2.dp),
+ maxLines = 1,
+ )
+ }
+ }
+ }
+
+ @SuppressLint("RestrictedApi")
+ @Composable
+ private fun NodeChip(shortName: String, colors: Pair, modifier: GlanceModifier = GlanceModifier) {
+ val (fg, bg) = colors
+ Row(
+ modifier =
+ modifier
+ .width(64.dp)
+ .background(Color(bg))
+ .cornerRadius(4.dp)
+ .padding(horizontal = 6.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ text = shortName,
+ style = TextStyle(color = ColorProvider(Color(fg)), fontSize = 11.sp, fontWeight = FontWeight.Bold),
+ )
+ }
+ }
+
+ @Composable
+ private fun StatRow(
+ label: String,
+ value: String?,
+ progress: Float,
+ isSmall: Boolean,
+ modifier: GlanceModifier = GlanceModifier,
+ ) {
+ Column(modifier = modifier.padding(vertical = 2.dp)) {
+ Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = label,
+ style =
+ TextStyle(
+ color = GlanceTheme.colors.onSurfaceVariant,
+ fontSize = if (isSmall) 10.sp else 11.sp,
+ ),
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ value?.let {
+ Text(
+ text = it,
+ style =
+ TextStyle(
+ color = GlanceTheme.colors.onSurface,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Medium,
+ ),
+ )
+ }
+ }
+ Spacer(GlanceModifier.height(2.dp))
+ LinearProgressIndicator(
+ progress = progress,
+ modifier = GlanceModifier.fillMaxWidth().height(4.dp).cornerRadius(2.dp),
+ color = GlanceTheme.colors.primary,
+ backgroundColor = GlanceTheme.colors.surfaceVariant,
+ )
+ }
+ }
+
+ companion object {
+ private val SMALL_SQUARE = DpSize(100.dp, 100.dp)
+ private val HORIZONTAL_RECTANGLE = DpSize(250.dp, 100.dp)
+ private val BIG_SQUARE = DpSize(250.dp, 250.dp)
+
+ private val RESPONSIVE_SIZES = setOf(SMALL_SQUARE, HORIZONTAL_RECTANGLE, BIG_SQUARE)
+ }
+}
+
+internal suspend fun createMockWidgetState() = LocalStatsWidgetUiState(
+ connectionState = ConnectionState.Connected,
+ showContent = true,
+ appName = getStringSuspend(Res.string.meshtastic_app_name),
+ nodesLabel = getStringSuspend(Res.string.nodes),
+ uptimeLabel = getStringSuspend(Res.string.uptime),
+ updatedLabel = getStringSuspend(Res.string.updated),
+ refreshLabel = getStringSuspend(Res.string.refresh),
+ nodeShortName = "ME",
+ nodeColors = 0xFFFFFFFF.toInt() to 0xFF000000.toInt(),
+ batteryLabel = getStringSuspend(Res.string.battery),
+ batteryValue = "85%",
+ batteryProgress = 0.85f,
+ channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization),
+ channelUtilizationValue = "18.5%",
+ channelUtilizationProgress = 0.185f,
+ airUtilizationLabel = getStringSuspend(Res.string.air_utilization),
+ airUtilizationValue = "3.2%",
+ airUtilizationProgress = 0.032f,
+ trafficText = "TX: 145 | RX: 892 | D: 42",
+ nodeCountText = "2/3",
+ uptimeText = "2d 0h",
+ updatedText = "5m ago",
+)
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetReceiver.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetReceiver.kt
new file mode 100644
index 000000000..39719efb4
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetReceiver.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 com.geeksville.mesh.widget
+
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class LocalStatsWidgetReceiver : GlanceAppWidgetReceiver() {
+ override val glanceAppWidget: GlanceAppWidget = LocalStatsWidget()
+}
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
new file mode 100644
index 000000000..7d6dea60b
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
@@ -0,0 +1,251 @@
+/*
+ * 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 com.geeksville.mesh.widget
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.util.formatUptime
+import org.meshtastic.core.model.util.onlineTimeThreshold
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.air_utilization
+import org.meshtastic.core.resources.battery
+import org.meshtastic.core.resources.channel_utilization
+import org.meshtastic.core.resources.connecting
+import org.meshtastic.core.resources.device_sleeping
+import org.meshtastic.core.resources.disconnected
+import org.meshtastic.core.resources.getStringSuspend
+import org.meshtastic.core.resources.local_stats_bad
+import org.meshtastic.core.resources.local_stats_diagnostics_prefix
+import org.meshtastic.core.resources.local_stats_dropped
+import org.meshtastic.core.resources.local_stats_heap
+import org.meshtastic.core.resources.local_stats_heap_value
+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_updated_at
+import org.meshtastic.core.resources.meshtastic_app_name
+import org.meshtastic.core.resources.nodes
+import org.meshtastic.core.resources.powered
+import org.meshtastic.core.resources.refresh
+import org.meshtastic.core.resources.updated
+import org.meshtastic.core.resources.uptime
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.LocalStats
+import javax.inject.Inject
+import javax.inject.Singleton
+
+data class LocalStatsWidgetUiState(
+ val connectionState: ConnectionState = ConnectionState.Disconnected,
+ // Rendering data
+ val statusText: String = "",
+ val isConnecting: Boolean = false,
+ val showContent: Boolean = false,
+
+ // Static Strings (Resolved in provider for Glance stability)
+ val appName: String = "",
+ val nodesLabel: String = "",
+ val uptimeLabel: String = "",
+ val updatedLabel: String = "",
+ val refreshLabel: String = "",
+
+ // Node Identity
+ val nodeShortName: String? = null,
+ val nodeColors: Pair? = null,
+
+ // Battery
+ val batteryLabel: String = "",
+ val batteryValue: String = "",
+ val batteryProgress: Float = 0f,
+
+ // Utilization
+ val channelUtilizationLabel: String = "",
+ val channelUtilizationValue: String = "",
+ val channelUtilizationProgress: Float = 0f,
+ val airUtilizationLabel: String = "",
+ val airUtilizationValue: String = "",
+ val airUtilizationProgress: Float = 0f,
+
+ // Packet Stats Lines
+ val trafficText: String? = null,
+ val relayText: String? = null,
+ val diagnosticsText: String? = null,
+ val heapFreeBytes: Int = 0,
+ val heapTotalBytes: Int = 0,
+ val heapValue: String? = null,
+ val heapText: String? = null,
+
+ // Footer
+ val nodeCountText: String = "",
+ val uptimeText: String = "",
+ val updatedText: String = "",
+)
+
+@Singleton
+class LocalStatsWidgetStateProvider
+@Inject
+constructor(
+ nodeRepository: NodeRepository,
+ serviceRepository: ServiceRepository,
+) {
+ private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
+
+ @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
+ val state: StateFlow =
+ combine(
+ serviceRepository.connectionState,
+ nodeRepository.nodeDBbyNum
+ .map { nodes ->
+ val online = nodes.values.count { it.lastHeard > onlineTimeThreshold() }
+ nodes.size to online
+ }
+ .distinctUntilChanged(),
+ nodeRepository.localStats,
+ nodeRepository.ourNodeInfo,
+ ) { connectionState, (totalNodes, onlineNodes), stats, localNode ->
+ StateInput(connectionState, totalNodes, onlineNodes, stats, localNode)
+ }
+ .map { input ->
+ mapToUiState(input.connectionState, input.totalNodes, input.onlineNodes, input.stats, input.localNode)
+ }
+ .distinctUntilChanged()
+ .stateIn(
+ scope = scope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = LocalStatsWidgetUiState(),
+ )
+
+ private data class StateInput(
+ val connectionState: ConnectionState,
+ val totalNodes: Int,
+ val onlineNodes: Int,
+ val stats: LocalStats,
+ val localNode: Node?,
+ )
+
+ @Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
+ private suspend fun mapToUiState(
+ connectionState: ConnectionState,
+ totalNodes: Int,
+ onlineNodes: Int,
+ stats: LocalStats,
+ localNode: Node?,
+ ): LocalStatsWidgetUiState {
+ val statusText =
+ when (connectionState) {
+ is ConnectionState.Disconnected -> getStringSuspend(Res.string.disconnected)
+ is ConnectionState.Connecting -> getStringSuspend(Res.string.connecting)
+ is ConnectionState.DeviceSleep -> getStringSuspend(Res.string.device_sleeping)
+ is ConnectionState.Connected -> ""
+ }
+
+ val metrics = localNode?.deviceMetrics
+ val batteryLevel = metrics?.battery_level ?: 0
+ val isPowered = batteryLevel > 100
+ val batteryValue = if (isPowered) getStringSuspend(Res.string.powered) else "$batteryLevel%"
+
+ val hasStats = stats.uptime_seconds != 0
+ val channelUtil = if (hasStats) stats.channel_utilization else metrics?.channel_utilization ?: 0f
+ val airUtilTx = if (hasStats) stats.air_util_tx else metrics?.air_util_tx ?: 0f
+
+ val diag = mutableListOf()
+ if (hasStats) {
+ if (stats.noise_floor != 0) {
+ diag.add(getStringSuspend(Res.string.local_stats_noise, stats.noise_floor))
+ }
+ if (stats.num_packets_rx_bad > 0) {
+ diag.add(getStringSuspend(Res.string.local_stats_bad, stats.num_packets_rx_bad))
+ }
+ if (stats.num_tx_dropped > 0) {
+ diag.add(getStringSuspend(Res.string.local_stats_dropped, stats.num_tx_dropped))
+ }
+ }
+
+ val uptimeSecs = if (hasStats) stats.uptime_seconds.toLong() else metrics?.uptime_seconds?.toLong() ?: 0L
+
+ return LocalStatsWidgetUiState(
+ connectionState = connectionState,
+ statusText = statusText,
+ isConnecting = connectionState is ConnectionState.Connecting,
+ showContent = connectionState is ConnectionState.Connected,
+ appName = getStringSuspend(Res.string.meshtastic_app_name),
+ nodesLabel = getStringSuspend(Res.string.nodes),
+ uptimeLabel = getStringSuspend(Res.string.uptime),
+ updatedLabel = getStringSuspend(Res.string.updated),
+ refreshLabel = getStringSuspend(Res.string.refresh),
+ nodeShortName = localNode?.user?.short_name,
+ nodeColors = localNode?.colors,
+ batteryLabel = getStringSuspend(Res.string.battery),
+ batteryValue = batteryValue,
+ batteryProgress = (batteryLevel / 100f).coerceIn(0f, 1f),
+ channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization),
+ channelUtilizationValue = "%.1f%%".format(channelUtil),
+ channelUtilizationProgress = (channelUtil / 100f).coerceIn(0f, 1f),
+ airUtilizationLabel = getStringSuspend(Res.string.air_utilization),
+ airUtilizationValue = "%.1f%%".format(airUtilTx),
+ airUtilizationProgress = (airUtilTx / 100f).coerceIn(0f, 1f),
+ trafficText =
+ if (hasStats) {
+ getStringSuspend(
+ Res.string.local_stats_traffic,
+ stats.num_packets_tx,
+ stats.num_packets_rx,
+ stats.num_rx_dupe,
+ )
+ } else {
+ null
+ },
+ relayText =
+ stats
+ .takeIf { hasStats && (it.num_tx_relay > 0 || it.num_tx_relay_canceled > 0) }
+ ?.let {
+ getStringSuspend(Res.string.local_stats_relays, it.num_tx_relay, it.num_tx_relay_canceled)
+ },
+ diagnosticsText =
+ if (diag.isNotEmpty()) {
+ getStringSuspend(Res.string.local_stats_diagnostics_prefix, diag.joinToString(" | "))
+ } else {
+ null
+ },
+ heapFreeBytes = if (hasStats) stats.heap_free_bytes else 0,
+ heapTotalBytes = if (hasStats) stats.heap_total_bytes else 0,
+ heapValue =
+ if (hasStats) {
+ getStringSuspend(Res.string.local_stats_heap_value, stats.heap_free_bytes, stats.heap_total_bytes)
+ } else {
+ null
+ },
+ heapText = if (hasStats) getStringSuspend(Res.string.local_stats_heap) else null,
+ nodeCountText = "$onlineNodes/$totalNodes",
+ uptimeText = formatUptime(uptimeSecs.toInt()),
+ updatedText = getStringSuspend(Res.string.local_stats_updated_at, DateFormatter.formatShortDate(nowMillis)),
+ )
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt b/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
new file mode 100644
index 000000000..16d6b566e
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.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 .
+ */
+package com.geeksville.mesh.widget
+
+import android.content.Context
+import androidx.glance.GlanceId
+import androidx.glance.action.ActionParameters
+import androidx.glance.appwidget.action.ActionCallback
+import com.geeksville.mesh.service.MeshCommandSender
+import com.geeksville.mesh.service.MeshNodeManager
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.model.TelemetryType
+
+class RefreshLocalStatsAction : ActionCallback {
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface RefreshLocalStatsEntryPoint {
+ fun commandSender(): MeshCommandSender
+
+ fun nodeManager(): MeshNodeManager
+ }
+
+ override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
+ val entryPoint =
+ EntryPointAccessors.fromApplication(context.applicationContext, RefreshLocalStatsEntryPoint::class.java)
+ val commandSender = entryPoint.commandSender()
+ val nodeManager = entryPoint.nodeManager()
+
+ val myNodeNum = nodeManager.myNodeNum ?: return
+
+ commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
+ commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
+ }
+}
diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml
new file mode 100644
index 000000000..3f20873d9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_refresh.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_local_stats_preview.xml b/app/src/main/res/layout/widget_local_stats_preview.xml
new file mode 100644
index 000000000..49092eaa7
--- /dev/null
+++ b/app/src/main/res/layout/widget_local_stats_preview.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/local_stats_widget_info.xml b/app/src/main/res/xml/local_stats_widget_info.xml
new file mode 100644
index 000000000..da9863cd9
--- /dev/null
+++ b/app/src/main/res/xml/local_stats_widget_info.xml
@@ -0,0 +1,25 @@
+
+
+
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
index 7249600c6..c7e002ec0 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
@@ -16,6 +16,9 @@
*/
package com.geeksville.mesh.service
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.updateAll
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import io.mockk.coEvery
import io.mockk.every
@@ -36,17 +39,20 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
+import org.meshtastic.core.database.model.Node
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.ToRadio
class MeshConnectionManagerTest {
+ private val context: Context = mockk(relaxed = true)
private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true)
private val connectionStateHolder = ConnectionStateHandler()
private val serviceBroadcasts: MeshServiceBroadcasts = mockk(relaxed = true)
@@ -72,16 +78,21 @@ class MeshConnectionManagerTest {
@Before
fun setUp() {
mockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
+ mockkStatic("androidx.glance.appwidget.GlanceAppWidgetKt")
coEvery { org.jetbrains.compose.resources.getString(any()) } returns "Mocked String"
coEvery { org.jetbrains.compose.resources.getString(any(), *anyVararg()) } returns "Mocked String"
+ coEvery { any().updateAll(any()) } returns Unit
every { radioInterfaceService.connectionState } returns radioConnectionState
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.localStats } returns MutableStateFlow(LocalStats())
manager =
MeshConnectionManager(
+ context,
radioInterfaceService,
connectionStateHolder,
serviceBroadcasts,
@@ -102,6 +113,7 @@ class MeshConnectionManagerTest {
@After
fun tearDown() {
unmockkStatic("org.jetbrains.compose.resources.StringResourcesKt")
+ unmockkStatic("androidx.glance.appwidget.GlanceAppWidgetKt")
}
@Test
diff --git a/app/src/test/java/com/geeksville/mesh/widget/LocalStatsWidgetStateProviderTest.kt b/app/src/test/java/com/geeksville/mesh/widget/LocalStatsWidgetStateProviderTest.kt
new file mode 100644
index 000000000..3d89d10d1
--- /dev/null
+++ b/app/src/test/java/com/geeksville/mesh/widget/LocalStatsWidgetStateProviderTest.kt
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 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 com.geeksville.mesh.widget
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.util.onlineTimeThreshold
+import org.meshtastic.core.resources.getStringSuspend
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.DeviceMetrics
+import org.meshtastic.proto.LocalStats
+import org.meshtastic.proto.User
+import org.robolectric.annotation.Config
+
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [34])
+@OptIn(ExperimentalCoroutinesApi::class)
+class LocalStatsWidgetStateProviderTest {
+
+ private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected)
+ private val nodeDbFlow = MutableStateFlow>(emptyMap())
+ private val localStatsFlow = MutableStateFlow(LocalStats())
+ private val ourNodeInfoFlow = MutableStateFlow(null)
+
+ private val serviceRepository = mockk(relaxed = true)
+ private val nodeRepository = mockk(relaxed = true)
+
+ @Before
+ fun setUp() {
+ mockkStatic("org.meshtastic.core.resources.ContextExtKt")
+ mockkStatic("org.meshtastic.core.model.util.TimeUtilsKt")
+
+ coEvery { getStringSuspend(any()) } returns "Mock String"
+ coEvery { getStringSuspend(any(), *anyVararg()) } returns "Mock Formatted String"
+ every { onlineTimeThreshold() } returns 0
+
+ // Explicitly return flows from mocks
+ every { serviceRepository.connectionState } returns connectionStateFlow
+ every { nodeRepository.nodeDBbyNum } returns nodeDbFlow
+ every { nodeRepository.localStats } returns localStatsFlow
+ every { nodeRepository.ourNodeInfo } returns ourNodeInfoFlow
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `initial state reflects disconnected status`() = runTest {
+ val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
+ val state = provider.state.first()
+ assertEquals(ConnectionState.Disconnected, state.connectionState)
+ assertFalse(state.showContent)
+ }
+
+ @Test
+ fun `connected state shows content and maps node info`() = runTest {
+ connectionStateFlow.value = ConnectionState.Connected
+ ourNodeInfoFlow.value =
+ Node(
+ num = 123,
+ user = User(short_name = "ABC"),
+ deviceMetrics = DeviceMetrics(battery_level = 85, channel_utilization = 12.5f),
+ )
+
+ val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
+ val state =
+ provider.state.first { (it.connectionState == ConnectionState.Connected) && (it.nodeShortName == "ABC") }
+
+ assertTrue(state.showContent)
+ assertEquals("ABC", state.nodeShortName)
+ assertEquals("85%", state.batteryValue)
+ }
+
+ @Test
+ fun `node count and update timestamp are populated`() = runTest {
+ connectionStateFlow.value = ConnectionState.Connected
+ nodeDbFlow.value = mapOf(1 to Node(num = 1, lastHeard = 1000))
+
+ val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
+ val state = provider.state.first { it.nodeCountText == "1/1" }
+
+ assertEquals("1/1", state.nodeCountText)
+ assertEquals("Mock Formatted String", state.updatedText)
+ }
+}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
index 6073f6807..8ea4e70be 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
@@ -42,11 +42,13 @@ import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
+import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.HardwareModel
+import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
import javax.inject.Inject
import javax.inject.Singleton
@@ -57,10 +59,11 @@ import javax.inject.Singleton
class NodeRepository
@Inject
constructor(
- @ProcessLifecycle processLifecycle: Lifecycle,
+ @ProcessLifecycle private val processLifecycle: Lifecycle,
private val nodeInfoReadDataSource: NodeInfoReadDataSource,
private val nodeInfoWriteDataSource: NodeInfoWriteDataSource,
private val dispatchers: CoroutineDispatchers,
+ private val localStatsDataSource: LocalStatsDataSource,
) {
/** Hardware info about our local device (can be null if not connected). */
val myNodeInfo: StateFlow =
@@ -81,6 +84,19 @@ constructor(
val myId: StateFlow
get() = _myId
+ /** The latest local stats telemetry received from the locally connected node. */
+ val localStats: StateFlow =
+ localStatsDataSource.localStatsFlow.stateIn(
+ processLifecycle.coroutineScope,
+ SharingStarted.Eagerly,
+ LocalStats(),
+ )
+
+ /** Update the cached local stats telemetry. */
+ fun updateLocalStats(stats: LocalStats) {
+ processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) }
+ }
+
/** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */
val nodeDBbyNum: StateFlow> =
nodeInfoReadDataSource
diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt
index 4a25e50d5..17e48b2be 100644
--- a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt
+++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/NodeRepositoryTest.kt
@@ -40,6 +40,7 @@ import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MyNodeEntity
+import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
@OptIn(ExperimentalCoroutinesApi::class)
@@ -49,6 +50,7 @@ class NodeRepositoryTest {
private val writeDataSource: NodeInfoWriteDataSource = mockk(relaxed = true)
private val lifecycle: Lifecycle = mockk(relaxed = true)
private val lifecycleScope: LifecycleCoroutineScope = mockk()
+ private val localStatsDataSource: LocalStatsDataSource = mockk(relaxed = true)
private val testDispatcher = StandardTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
@@ -88,7 +90,8 @@ class NodeRepositoryTest {
val myNodeNum = 12345
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
- val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers)
+ val repository =
+ NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first()
@@ -102,7 +105,8 @@ class NodeRepositoryTest {
val remoteNodeNum = 67890
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
- val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers)
+ val repository =
+ NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
val result = repository.effectiveLogNodeId(remoteNodeNum).first()
@@ -117,7 +121,8 @@ class NodeRepositoryTest {
val targetNodeNum = 111
myNodeInfoFlow.value = createMyNodeEntity(firstNodeNum)
- val repository = NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers)
+ val repository =
+ NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
// Initially should be mapped to LOCAL because it matches
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalStatsDataSource.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalStatsDataSource.kt
new file mode 100644
index 000000000..22ee35390
--- /dev/null
+++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/LocalStatsDataSource.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.datastore
+
+import androidx.datastore.core.DataStore
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import okio.IOException
+import org.meshtastic.proto.LocalStats
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Class that handles saving and retrieving [LocalStats] data. */
+@Singleton
+class LocalStatsDataSource @Inject constructor(private val localStatsStore: DataStore) {
+ val localStatsFlow: Flow =
+ localStatsStore.data.catch { exception ->
+ if (exception is IOException) {
+ Logger.e { "Error reading LocalStats: ${exception.message}" }
+ emit(LocalStats())
+ } else {
+ throw exception
+ }
+ }
+
+ suspend fun setLocalStats(stats: LocalStats) {
+ localStatsStore.updateData { stats }
+ }
+
+ suspend fun clearLocalStats() {
+ localStatsStore.updateData { LocalStats() }
+ }
+}
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt
index a51523b22..079be59b7 100644
--- a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt
+++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/di/DataStoreModule.kt
@@ -43,10 +43,12 @@ import org.meshtastic.core.datastore.KEY_SHOW_IGNORED
import org.meshtastic.core.datastore.KEY_THEME
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
+import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.LocalStats
import javax.inject.Qualifier
import javax.inject.Singleton
@@ -129,4 +131,16 @@ object DataStoreModule {
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
scope = scope,
)
+
+ @Singleton
+ @Provides
+ fun provideLocalStatsDataStore(
+ @ApplicationContext appContext: Context,
+ @DataStoreScope scope: CoroutineScope,
+ ): DataStore = DataStoreFactory.create(
+ serializer = LocalStatsSerializer,
+ produceFile = { appContext.dataStoreFile("local_stats.pb") },
+ corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }),
+ scope = scope,
+ )
}
diff --git a/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
new file mode 100644
index 000000000..8f1e2d68f
--- /dev/null
+++ b/core/datastore/src/main/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.datastore.serializer
+
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import okio.IOException
+import org.meshtastic.proto.LocalStats
+import java.io.InputStream
+import java.io.OutputStream
+
+/** Serializer for the [LocalStats] object defined in telemetry.proto. */
+@Suppress("BlockingMethodInNonBlockingContext")
+object LocalStatsSerializer : Serializer {
+ override val defaultValue: LocalStats = LocalStats()
+
+ override suspend fun readFrom(input: InputStream): LocalStats {
+ try {
+ return LocalStats.ADAPTER.decode(input)
+ } catch (exception: IOException) {
+ throw CorruptionException("Cannot read proto.", exception)
+ }
+ }
+
+ override suspend fun writeTo(t: LocalStats, output: OutputStream) = LocalStats.ADAPTER.encode(output, t)
+}
diff --git a/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt b/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt
index 5ebc91250..0b95a5a79 100644
--- a/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt
+++ b/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt
@@ -18,23 +18,36 @@ package org.meshtastic.core.resources
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.StringResource
-import org.jetbrains.compose.resources.getString
+import org.jetbrains.compose.resources.getString as composeGetString
/** Retrieves a string from the [StringResource] in a blocking manner. Use primarily in non-composable code. */
-fun getString(stringResource: StringResource): String = runBlocking {
- org.jetbrains.compose.resources.getString(stringResource)
-}
+fun getString(stringResource: StringResource): String = runBlocking { composeGetString(stringResource) }
/** Retrieves a formatted string from the [StringResource] in a blocking manner. */
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
- val resolvedArgs =
- formatArgs.map { arg ->
- if (arg is StringResource) {
- getString(arg)
- } else {
- arg
- }
- }
- @Suppress("SpreadOperator")
- org.jetbrains.compose.resources.getString(stringResource, *resolvedArgs.toTypedArray())
+ getStringSuspend(stringResource, *formatArgs)
+}
+
+/** Retrieves a string from the [StringResource] in a suspending manner. */
+suspend fun getStringSuspend(stringResource: StringResource): String = composeGetString(stringResource)
+
+/** Retrieves a formatted string from the [StringResource] in a suspending manner. */
+suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs: Any): String {
+ val resolvedArgs =
+ formatArgs
+ .map { arg ->
+ if (arg is StringResource) {
+ // Resolve nested StringResources recursively
+ getStringSuspend(arg)
+ } else {
+ arg
+ }
+ }
+ .toTypedArray()
+
+ // Compose Multiplatform doesn't fully support complex formatting like %.2f
+ // Fetch the raw string and format it using standard Java String.format.
+ val rawString = composeGetString(stringResource)
+ @Suppress("SpreadOperator")
+ return String.format(java.util.Locale.getDefault(), rawString, *resolvedArgs)
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index a426350e4..ad08c00a2 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1199,10 +1199,17 @@
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)
+ Traffic: TX %1$d / RX %2$d (D: %3$d)
Relays: %1$d (Canceled: %2$d)
Diagnostics: %1$s
Noise %1$d dBm
Bad %1$d
Dropped %1$d
+ Heap
+ %1$d / %2$d
+ %1$s
+ Powered
+ Meshtastic Stats
+ Refresh
+ Updated
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
index 3c29a0d64..d7acde4dd 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
@@ -17,7 +17,7 @@
package org.meshtastic.feature.messaging.ui.contact
import android.net.Uri
-import androidx.activity.compose.BackHandler
+import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -43,6 +43,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@@ -93,7 +94,16 @@ fun AdaptiveContactsScreen(
}
}
- BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() }
+ PredictiveBackHandler(
+ enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
+ ) { progress ->
+ try {
+ progress.collect { /* Predictive back progress could be used here to drive UI if scaffold supported it */ }
+ handleBack()
+ } catch (_: CancellationException) {
+ // Gesture cancelled
+ }
+ }
LaunchedEffect(initialContactKey) {
if (initialContactKey != null) {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index fb9c5beeb..5fcc4f0d6 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -9,6 +9,7 @@ androidxComposeMaterial3Adaptive = "1.2.0"
androidxHilt = "1.3.0"
androidxTracing = "1.10.3"
datastore = "1.2.0"
+glance = "1.2.0-rc01"
lifecycle = "2.10.0"
navigation = "2.9.7"
navigation3 = "1.0.1"
@@ -77,6 +78,10 @@ androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", versi
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-emoji2-emojipicker = { module = "androidx.emoji2:emoji2-emojipicker", version = "1.6.0" }
+androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }
+androidx-glance-appwidget-preview = { module = "androidx.glance:glance-appwidget-preview", version.ref = "glance" }
+androidx-glance-preview = { module = "androidx.glance:glance-preview", version.ref = "glance" }
+androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidxHilt" }
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidxHilt" }
From ceeb28945d203e2d0655c20680a3ee64ca55df02 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 25 Feb 2026 14:12:08 -0600
Subject: [PATCH 015/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4646)
---
app/src/main/assets/firmware_releases.json | 12 ++++++------
.../composeResources/values-de/strings.xml | 2 +-
.../composeResources/values-et/strings.xml | 1 -
.../composeResources/values-fi/strings.xml | 1 -
.../composeResources/values-sr/strings.xml | 1 +
.../composeResources/values-srp/strings.xml | 1 +
6 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index c2f2de895..898ec9bbe 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -188,6 +188,12 @@
]
},
"pullRequests": [
+ {
+ "id": "9749",
+ "title": "Add AEAD (AES-CCM) authenticated encryption for PSK channels",
+ "page_url": "https://github.com/meshtastic/firmware/pull/9749",
+ "zip_url": "https://discord.com/invite/meshtastic"
+ },
{
"id": "9709",
"title": "platform: nrf52: Fix typo in BLEDfuSecure filename",
@@ -211,12 +217,6 @@
"title": "Add phone API fanout for multiple clients",
"page_url": "https://github.com/meshtastic/firmware/pull/9627",
"zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9586",
- "title": "feat(esp32): add support for Native ESP32 Ethernet and WT32-ETH01 variant",
- "page_url": "https://github.com/meshtastic/firmware/pull/9586",
- "zip_url": "https://discord.com/invite/meshtastic"
}
]
}
\ No newline at end of file
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index 88ae5fbc5..baed4ffc7 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -1147,10 +1147,10 @@
Knoten: %1$d online / %2$d gesamt
Laufzeit: %1$s
Kanalauslastung: %1$.2f%% | Sendezeit: %2$.2f%%
- Datenverkehr: TX %1$d / RX %2$d (Duplikate: %3$d)
Weiterleitungen: %1$d (Abgebrochen: %2$d)
Diagnose %1$s
Rauschen %1$d dBm
Fehlerhaft %1$d
Verworfen %1$d
+ Angeschaltet
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index 89209b701..10c480f27 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -1147,7 +1147,6 @@
Sõlmed: %1$d võrgus / %2$d kokku
Töös: %1$s
ChUtil: %1$.2f%% | AirTX: %2$.2f%%
- Ühendus: TX %1$d / RX %2$d (Dupes: %3$d)
Vahendatud: %1$d (Tühistatud: %2$d)
Diagnostika: %1$s
Müra %1$d dBm
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 67de5647c..f47012370 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -1148,7 +1148,6 @@
Laitteet: %1$d verkossa / %2$d yhteensä
Käyttöaika: %1$s
Kanavan käytöaste: %1$.2f%% | Lähestysajan käyttöaste: %2$.2f%%
- Liikenne: Lähetetyt %1$d / Vastaanotetut %2$d (Kaksoiskappaleet: %3$d)
Välitetyt: %1$d (Peruutetut: %2$d)
Vianmääritys: %1$s
Kohinataso %1$d dBm
diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
index aa81c2e71..f8a2e38a9 100644
--- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
@@ -454,4 +454,5 @@
Генерисање QR кода
Сви
Блутут
+ Напајано
diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
index fed146525..e35037d52 100644
--- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
@@ -454,4 +454,5 @@
Генерисање QR кода
Сви
Блутут
+ Напајано
From 85c6ed61bbebe40cd4418b515ab71709aea99448 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 25 Feb 2026 20:44:13 -0600
Subject: [PATCH 016/474] refactor(analytics): reduce tracking footprint
(#4649)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/src/main/AndroidManifest.xml | 15 ++
.../LocalStatsWidgetStateProviderTest.kt | 119 ---------------
.../platform/GooglePlatformAnalytics.kt | 136 ++++++++++++------
.../core/prefs/analytics/AnalyticsPrefs.kt | 2 +-
4 files changed, 109 insertions(+), 163 deletions(-)
delete mode 100644 app/src/test/java/com/geeksville/mesh/widget/LocalStatsWidgetStateProviderTest.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 64d43a759..90a786cb1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -135,6 +135,21 @@
android:name="google_analytics_default_allow_ad_personalization_signals"
android:value="false" />
+
+
+
+
+
+
+
+
+
.
- */
-package com.geeksville.mesh.widget
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import io.mockk.coEvery
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.mockkStatic
-import io.mockk.unmockkAll
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.test.runTest
-import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.model.util.onlineTimeThreshold
-import org.meshtastic.core.resources.getStringSuspend
-import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.proto.DeviceMetrics
-import org.meshtastic.proto.LocalStats
-import org.meshtastic.proto.User
-import org.robolectric.annotation.Config
-
-@RunWith(AndroidJUnit4::class)
-@Config(sdk = [34])
-@OptIn(ExperimentalCoroutinesApi::class)
-class LocalStatsWidgetStateProviderTest {
-
- private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected)
- private val nodeDbFlow = MutableStateFlow>(emptyMap())
- private val localStatsFlow = MutableStateFlow(LocalStats())
- private val ourNodeInfoFlow = MutableStateFlow(null)
-
- private val serviceRepository = mockk(relaxed = true)
- private val nodeRepository = mockk(relaxed = true)
-
- @Before
- fun setUp() {
- mockkStatic("org.meshtastic.core.resources.ContextExtKt")
- mockkStatic("org.meshtastic.core.model.util.TimeUtilsKt")
-
- coEvery { getStringSuspend(any()) } returns "Mock String"
- coEvery { getStringSuspend(any(), *anyVararg()) } returns "Mock Formatted String"
- every { onlineTimeThreshold() } returns 0
-
- // Explicitly return flows from mocks
- every { serviceRepository.connectionState } returns connectionStateFlow
- every { nodeRepository.nodeDBbyNum } returns nodeDbFlow
- every { nodeRepository.localStats } returns localStatsFlow
- every { nodeRepository.ourNodeInfo } returns ourNodeInfoFlow
- }
-
- @After
- fun tearDown() {
- unmockkAll()
- }
-
- @Test
- fun `initial state reflects disconnected status`() = runTest {
- val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
- val state = provider.state.first()
- assertEquals(ConnectionState.Disconnected, state.connectionState)
- assertFalse(state.showContent)
- }
-
- @Test
- fun `connected state shows content and maps node info`() = runTest {
- connectionStateFlow.value = ConnectionState.Connected
- ourNodeInfoFlow.value =
- Node(
- num = 123,
- user = User(short_name = "ABC"),
- deviceMetrics = DeviceMetrics(battery_level = 85, channel_utilization = 12.5f),
- )
-
- val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
- val state =
- provider.state.first { (it.connectionState == ConnectionState.Connected) && (it.nodeShortName == "ABC") }
-
- assertTrue(state.showContent)
- assertEquals("ABC", state.nodeShortName)
- assertEquals("85%", state.batteryValue)
- }
-
- @Test
- fun `node count and update timestamp are populated`() = runTest {
- connectionStateFlow.value = ConnectionState.Connected
- nodeDbFlow.value = mapOf(1 to Node(num = 1, lastHeard = 1000))
-
- val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository)
- val state = provider.state.first { it.nodeCountText == "1/1" }
-
- assertEquals("1/1", state.nodeCountText)
- assertEquals("Mock Formatted String", state.updatedText)
- }
-}
diff --git a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
index a9786bd4e..7bd13f840 100644
--- a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
+++ b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt
@@ -30,7 +30,6 @@ import com.datadog.android.Datadog
import com.datadog.android.DatadogSite
import com.datadog.android.compose.ExperimentalTrackingApi
import com.datadog.android.compose.NavigationViewTrackingEffect
-import com.datadog.android.compose.enableComposeActionTracking
import com.datadog.android.core.configuration.Configuration
import com.datadog.android.log.Logger
import com.datadog.android.log.Logs
@@ -46,6 +45,8 @@ import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailabilityLight
import com.google.firebase.Firebase
+import com.google.firebase.analytics.FirebaseAnalytics.ConsentStatus
+import com.google.firebase.analytics.FirebaseAnalytics.ConsentType
import com.google.firebase.analytics.analytics
import com.google.firebase.crashlytics.crashlytics
import com.google.firebase.crashlytics.setCustomKeys
@@ -64,16 +65,22 @@ import co.touchlab.kermit.Logger as KermitLogger
/**
* Google Play Services specific implementation of [PlatformAnalytics]. This helper initializes and manages Firebase and
* Datadog services, and subscribes to analytics preference changes to update consent accordingly.
+ *
+ * This implementation delays initialization of SDKs until user consent is granted to reduce tracking "noise" and
+ * respect privacy-focused environments.
*/
class GooglePlatformAnalytics
@Inject
constructor(
@ApplicationContext private val context: Context,
- analyticsPrefs: AnalyticsPrefs,
+ private val analyticsPrefs: AnalyticsPrefs,
) : PlatformAnalytics {
private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate
+ private var datadogLogger: Logger? = null
+ private var isFirebaseInitialized = false
+
private val isInTestLab: Boolean
get() {
val testLabSetting = Settings.System.getString(context.contentResolver, "firebase.test.lab")
@@ -83,22 +90,16 @@ constructor(
companion object {
private const val TAG = "GooglePlatformAnalytics"
private const val SERVICE_NAME = "org.meshtastic"
+
+ private const val KEY_PRIORITY = "priority"
+ private const val KEY_TAG = "tag"
+ private const val KEY_MESSAGE = "message"
}
init {
- initDatadog(context as Application)
- initCrashlytics(context as Application)
-
- val datadogLogger =
- Logger.Builder()
- .setService(SERVICE_NAME)
- .setNetworkInfoEnabled(false) // Disable to avoid collecting Local IP/SSID
- .setRemoteSampleRate(sampleRate)
- .setBundleWithTraceEnabled(true)
- .setBundleWithRumEnabled(true)
- .build()
+ // Setup Kermit log writers immediately, they will handle delayed SDK initialization gracefully.
val writers = buildList {
- add(DatadogLogWriter(datadogLogger))
+ add(DatadogLogWriter())
add(CrashlyticsLogWriter())
if (BuildConfig.DEBUG) {
add(co.touchlab.kermit.LogcatWriter())
@@ -117,6 +118,30 @@ constructor(
.launchIn(ProcessLifecycleOwner.get().lifecycleScope)
}
+ /**
+ * Ensures that Datadog and Firebase SDKs are initialized if allowed. This is called lazily when consent is granted.
+ */
+ private fun ensureInitialized() {
+ if (!analyticsPrefs.analyticsAllowed || isInTestLab) return
+
+ if (!Datadog.isInitialized()) {
+ initDatadog(context as Application)
+ datadogLogger =
+ Logger.Builder()
+ .setService(SERVICE_NAME)
+ .setNetworkInfoEnabled(false) // Disable to avoid collecting Local IP/SSID
+ .setRemoteSampleRate(sampleRate)
+ .setBundleWithTraceEnabled(true)
+ .setBundleWithRumEnabled(true)
+ .build()
+ }
+
+ if (!isFirebaseInitialized) {
+ initCrashlytics(context as Application)
+ isFirebaseInitialized = true
+ }
+ }
+
private fun initDatadog(application: Application) {
val configuration =
Configuration.Builder(
@@ -135,13 +160,11 @@ constructor(
val rumConfiguration =
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
.trackAnonymousUser(true)
- .trackBackgroundEvents(true)
- .trackFrustrations(true)
+ .trackBackgroundEvents(false) // Disable background noise
+ .trackFrustrations(false) // Disable click-tracking based frustration detection
.trackLongTasks()
.trackNonFatalAnrs(true)
- .trackUserInteractions()
.setSessionSampleRate(sampleRate)
- .enableComposeActionTracking()
.build()
Rum.enable(rumConfiguration)
@@ -153,12 +176,24 @@ constructor(
GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
- // Session Replay disabled to reduce PII collection as requested
+ // Session Replay disabled to reduce PII collection
}
private fun initCrashlytics(application: Application) {
Firebase.initialize(application)
- // User ID tracking disabled to avoid collecting Unique Identifier PII
+
+ // Deny all ad-related consent types by default to minimize tracking noise
+ Firebase.analytics.setConsent(
+ mapOf(
+ ConsentType.AD_STORAGE to ConsentStatus.DENIED,
+ ConsentType.AD_USER_DATA to ConsentStatus.DENIED,
+ ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED,
+ ConsentType.ANALYTICS_STORAGE to ConsentStatus.DENIED,
+ ),
+ )
+
+ // Explicitly disable analytics collection until we confirm user consent
+ Firebase.analytics.setAnalyticsCollectionEnabled(false)
}
/**
@@ -167,18 +202,39 @@ constructor(
* @param allowed True if analytics are allowed, false otherwise.
*/
fun updateAnalyticsConsent(allowed: Boolean) {
- if (!isPlatformServicesAvailable || isInTestLab) {
- KermitLogger.i { "Analytics not available or in test lab, consent update skipped." }
- return
- }
- KermitLogger.i { if (allowed) "Analytics enabled" else "Analytics disabled" }
-
- Datadog.setTrackingConsent(if (allowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED)
- Firebase.crashlytics.isCrashlyticsCollectionEnabled = allowed
- Firebase.analytics.setAnalyticsCollectionEnabled(allowed)
+ if (isInTestLab) return
if (allowed) {
- Firebase.crashlytics.sendUnsentReports()
+ ensureInitialized()
+ }
+
+ KermitLogger.i { if (allowed) "Analytics enabled" else "Analytics disabled" }
+
+ if (Datadog.isInitialized()) {
+ Datadog.setTrackingConsent(if (allowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED)
+ }
+
+ if (isFirebaseInitialized) {
+ Firebase.crashlytics.isCrashlyticsCollectionEnabled = allowed
+ Firebase.analytics.setAnalyticsCollectionEnabled(allowed)
+
+ if (allowed) {
+ Firebase.crashlytics.sendUnsentReports()
+ // Ensure ad-related PII collection remains disabled even if analytics is allowed
+ Firebase.analytics.setUserProperty("allow_personalized_ads", "false")
+ }
+
+ // Manage Analytics Storage consent for Advanced Consent Mode
+ val consentStatus = if (allowed) ConsentStatus.GRANTED else ConsentStatus.DENIED
+ Firebase.analytics.setConsent(
+ mapOf(
+ ConsentType.ANALYTICS_STORAGE to consentStatus,
+ // Keep ad-related types explicitly denied
+ ConsentType.AD_STORAGE to ConsentStatus.DENIED,
+ ConsentType.AD_USER_DATA to ConsentStatus.DENIED,
+ ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED,
+ ),
+ )
}
}
@@ -206,20 +262,12 @@ constructor(
it != ConnectionResult.SERVICE_MISSING && it != ConnectionResult.SERVICE_INVALID
}
- private val isDatadogAvailable: Boolean
- get() = Datadog.isInitialized()
-
override val isPlatformServicesAvailable: Boolean
- get() = isGooglePlayAvailable && isDatadogAvailable
-
- private class CrashlyticsLogWriter : LogWriter() {
- companion object {
- private const val KEY_PRIORITY = "priority"
- private const val KEY_TAG = "tag"
- private const val KEY_MESSAGE = "message"
- }
+ get() = isGooglePlayAvailable
+ private inner class CrashlyticsLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
+ if (!isFirebaseInitialized) return
if (!Firebase.crashlytics.isCrashlyticsCollectionEnabled) return
// Add the log to the Crashlytics log buffer so it appears in reports
@@ -244,8 +292,9 @@ constructor(
}
}
- private class DatadogLogWriter(private val datadogLogger: Logger) : LogWriter() {
+ private inner class DatadogLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
+ val logger = datadogLogger ?: return
val datadogPriority =
when (severity) {
Severity.Verbose -> android.util.Log.VERBOSE
@@ -255,7 +304,7 @@ constructor(
Severity.Error -> android.util.Log.ERROR
Severity.Assert -> android.util.Log.ASSERT
}
- datadogLogger.log(datadogPriority, message, throwable, mapOf("tag" to tag))
+ logger.log(datadogPriority, message, throwable, mapOf("tag" to tag))
}
}
@@ -266,6 +315,7 @@ constructor(
}
override fun track(event: String, vararg properties: DataPair) {
+ if (!isFirebaseInitialized) return
val bundle = Bundle()
properties.forEach {
when (it.value) {
diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt
index f2ed41a8c..bb7592a1e 100644
--- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt
+++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt
@@ -60,7 +60,7 @@ constructor(
@AppSharedPreferences appPrefs: SharedPreferences,
) : AnalyticsPrefs {
override var analyticsAllowed: Boolean by
- PrefDelegate(analyticsSharedPreferences, AnalyticsPrefs.KEY_ANALYTICS_ALLOWED, true)
+ PrefDelegate(analyticsSharedPreferences, AnalyticsPrefs.KEY_ANALYTICS_ALLOWED, false)
private var _installId: String? by NullableStringPrefDelegate(appPrefs, "appPrefs_install_id", null)
From 54c4ace5eb058d06440a170a6d08791472142367 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Wed, 25 Feb 2026 20:45:56 -0600
Subject: [PATCH 017/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4650)
---
app/src/main/assets/firmware_releases.json | 12 ++++++------
.../composeResources/values-fi/strings.xml | 8 ++++++++
2 files changed, 14 insertions(+), 6 deletions(-)
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 898ec9bbe..432afbed7 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -194,12 +194,6 @@
"page_url": "https://github.com/meshtastic/firmware/pull/9749",
"zip_url": "https://discord.com/invite/meshtastic"
},
- {
- "id": "9709",
- "title": "platform: nrf52: Fix typo in BLEDfuSecure filename",
- "page_url": "https://github.com/meshtastic/firmware/pull/9709",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
{
"id": "9706",
"title": "Add VL53L0 distance sensor.",
@@ -217,6 +211,12 @@
"title": "Add phone API fanout for multiple clients",
"page_url": "https://github.com/meshtastic/firmware/pull/9627",
"zip_url": "https://discord.com/invite/meshtastic"
+ },
+ {
+ "id": "9586",
+ "title": "feat(esp32): add support for Native ESP32 Ethernet and WT32-ETH01 variant",
+ "page_url": "https://github.com/meshtastic/firmware/pull/9586",
+ "zip_url": "https://discord.com/invite/meshtastic"
}
]
}
\ No newline at end of file
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index f47012370..4d9d6143d 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -1148,9 +1148,17 @@
Laitteet: %1$d verkossa / %2$d yhteensä
Käyttöaika: %1$s
Kanavan käytöaste: %1$.2f%% | Lähestysajan käyttöaste: %2$.2f%%
+ Liikenne: Lähetetty %1$d / Vastaanotettu %2$d (Hylätty: %3$d)
Välitetyt: %1$d (Peruutetut: %2$d)
Vianmääritys: %1$s
Kohinataso %1$d dBm
Huonot %1$d
Hylätyt paketit %1$d
+ Vapaan muistin määrä
+ %1$d / %2$d
+ %1$s
+ Powered
+ Meshtastic tilastot
+ Päivitä
+ Päivitetty
From a683dcb04f6c0403fb3a475eff6be9fa56fb81d6 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 26 Feb 2026 03:09:42 +0000
Subject: [PATCH 018/474] chore(deps): update
androidx.compose.runtime:runtime-tracing to v1.10.4 (#4648)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5fcc4f0d6..97b07a05e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -7,7 +7,7 @@ accompanist = "0.37.3"
# androidx
androidxComposeMaterial3Adaptive = "1.2.0"
androidxHilt = "1.3.0"
-androidxTracing = "1.10.3"
+androidxTracing = "1.10.4"
datastore = "1.2.0"
glance = "1.2.0-rc01"
lifecycle = "2.10.0"
From 46b32f1cce77b07909a4bfc4766728eaf0be3c1c Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 25 Feb 2026 21:11:42 -0600
Subject: [PATCH 019/474] chore(deps): update actions/attest-build-provenance
action to v4 (#4652)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/release.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9cb7227c4..15cd3555b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -197,7 +197,7 @@ jobs:
- name: Attest Google artifacts provenance
if: always()
- uses: actions/attest-build-provenance@v3
+ uses: actions/attest-build-provenance@v4
with:
subject-path: |
app/build/outputs/bundle/googleRelease/app-google-release.aab
@@ -264,7 +264,7 @@ jobs:
retention-days: 1
- name: Attest F-Droid APK provenance
- uses: actions/attest-build-provenance@v3
+ uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/apk/**/*.apk
From 145cde93930d1d246d64769566c92a163c1cf9ec Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 26 Feb 2026 07:26:50 -0600
Subject: [PATCH 020/474] chore(deps): bump deps to take advantage of new
functionality (#4658)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../mesh/widget/LocalStatsWidget.kt | 156 ++++++++++++-----
.../mesh/widget/LocalStatsWidgetState.kt | 159 +++++-------------
.../core/network/di/GoogleNetworkModule.kt | 23 +--
.../core/network/di/NetworkModule.kt | 25 ++-
.../meshtastic/core/resources/ContextExt.kt | 8 +-
.../core/ui/util/ContextExtensions.kt | 6 +-
feature/firmware/build.gradle.kts | 2 +
.../feature/firmware/FirmwareFileHandler.kt | 31 ++--
.../map/component/NodeClusterMarkers.kt | 32 ++--
.../feature/settings/debugging/Debug.kt | 22 ++-
gradle/libs.versions.toml | 3 +-
11 files changed, 230 insertions(+), 237 deletions(-)
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
index 2be3f1878..7de8359eb 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
@@ -67,13 +67,28 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
import org.meshtastic.core.resources.battery
import org.meshtastic.core.resources.channel_utilization
-import org.meshtastic.core.resources.getStringSuspend
+import org.meshtastic.core.resources.connecting
+import org.meshtastic.core.resources.device_sleeping
+import org.meshtastic.core.resources.disconnected
+import org.meshtastic.core.resources.local_stats_bad
+import org.meshtastic.core.resources.local_stats_diagnostics_prefix
+import org.meshtastic.core.resources.local_stats_dropped
+import org.meshtastic.core.resources.local_stats_heap
+import org.meshtastic.core.resources.local_stats_heap_value
+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_updated_at
import org.meshtastic.core.resources.meshtastic_app_name
import org.meshtastic.core.resources.nodes
+import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.updated
import org.meshtastic.core.resources.uptime
@@ -129,11 +144,11 @@ class LocalStatsWidget : GlanceAppWidget() {
titleBar = {
TitleBar(
startIcon = ImageProvider(com.geeksville.mesh.R.drawable.app_icon),
- title = state.appName,
+ title = stringResource(Res.string.meshtastic_app_name),
actions = {
CircleIconButton(
imageProvider = ImageProvider(com.geeksville.mesh.R.drawable.ic_refresh),
- contentDescription = state.refreshLabel,
+ contentDescription = stringResource(Res.string.refresh),
onClick = actionRunCallback(),
backgroundColor = null,
)
@@ -154,6 +169,7 @@ class LocalStatsWidget : GlanceAppWidget() {
}
@Composable
+ @Suppress("LongMethod", "MagicNumber")
private fun FullStatsContent(state: LocalStatsWidgetUiState) {
val size = LocalSize.current
val isNarrow = size.width < 160.dp
@@ -168,13 +184,20 @@ class LocalStatsWidget : GlanceAppWidget() {
state.nodeColors?.let { colors -> NodeChip(shortName = name, colors = colors) }
}
Spacer(GlanceModifier.width(8.dp))
- StatRow(
- label = state.batteryLabel,
- value = state.batteryValue,
- progress = state.batteryProgress,
- isSmall = isSmall,
- modifier = GlanceModifier.defaultWeight(),
- )
+ if (state.hasBattery) {
+ val isPowered = state.batteryLevel > 100
+ val batteryValue =
+ if (isPowered) stringResource(Res.string.powered) else "${state.batteryLevel}%"
+ StatRow(
+ label = stringResource(Res.string.battery),
+ value = batteryValue,
+ progress = state.batteryProgress,
+ isSmall = isSmall,
+ modifier = GlanceModifier.defaultWeight(),
+ )
+ } else {
+ Spacer(GlanceModifier.defaultWeight())
+ }
}
Spacer(GlanceModifier.height(2.dp))
@@ -183,15 +206,15 @@ class LocalStatsWidget : GlanceAppWidget() {
Row(modifier = GlanceModifier.fillMaxWidth()) {
StatRow(
- label = state.channelUtilizationLabel,
- value = state.channelUtilizationValue,
+ label = stringResource(Res.string.channel_utilization),
+ value = "%.1f%%".format(state.channelUtilization),
progress = state.channelUtilizationProgress,
isSmall = isSmall,
modifier = GlanceModifier.defaultWeight().padding(end = 4.dp),
)
StatRow(
- label = state.airUtilizationLabel,
- value = state.airUtilizationValue,
+ label = stringResource(Res.string.air_utilization),
+ value = "%.1f%%".format(state.airUtilization),
progress = state.airUtilizationProgress,
isSmall = isSmall,
modifier = GlanceModifier.defaultWeight().padding(start = 4.dp),
@@ -201,17 +224,53 @@ class LocalStatsWidget : GlanceAppWidget() {
// Detailed Traffic/Relay Stats
Spacer(GlanceModifier.height(2.dp))
Column(modifier = GlanceModifier.fillMaxWidth()) {
- state.trafficText?.let { StatText(it, isSmall) }
- state.relayText?.let { StatText(it, isSmall) }
- state.diagnosticsText?.let { StatText(it, isSmall) }
- state.heapText?.let {
+ if (state.hasStats) {
+ StatText(
+ stringResource(
+ Res.string.local_stats_traffic,
+ state.numPacketsTx,
+ state.numPacketsRx,
+ state.numRxDupe,
+ ),
+ isSmall,
+ )
+ if (state.numTxRelay > 0 || state.numTxRelayCanceled > 0) {
+ StatText(
+ stringResource(
+ Res.string.local_stats_relays,
+ state.numTxRelay,
+ state.numTxRelayCanceled,
+ ),
+ isSmall,
+ )
+ }
+
+ val diag = mutableListOf()
+ if (state.noiseFloor != 0) {
+ diag.add(stringResource(Res.string.local_stats_noise, state.noiseFloor))
+ }
+ if (state.numPacketsRxBad > 0) {
+ diag.add(stringResource(Res.string.local_stats_bad, state.numPacketsRxBad))
+ }
+ if (state.numTxDropped > 0) {
+ diag.add(stringResource(Res.string.local_stats_dropped, state.numTxDropped))
+ }
+ if (diag.isNotEmpty()) {
+ StatText(
+ stringResource(Res.string.local_stats_diagnostics_prefix, diag.joinToString(" | ")),
+ isSmall,
+ )
+ }
+
val heapProgress =
if (state.heapTotalBytes > 0) {
state.heapFreeBytes.toFloat() / state.heapTotalBytes
} else {
0f
}
- StatRow(it, state.heapValue, heapProgress, isSmall)
+ val heapValue =
+ stringResource(Res.string.local_stats_heap_value, state.heapFreeBytes, state.heapTotalBytes)
+ StatRow(stringResource(Res.string.local_stats_heap), heapValue, heapProgress, isSmall)
}
}
}
@@ -246,8 +305,15 @@ class LocalStatsWidget : GlanceAppWidget() {
modifier = GlanceModifier.size(32.dp),
)
}
+ val statusText =
+ when (state.connectionState) {
+ is ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
+ is ConnectionState.Connecting -> stringResource(Res.string.connecting)
+ is ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping)
+ is ConnectionState.Connected -> ""
+ }
Text(
- text = state.statusText,
+ text = statusText,
style =
TextStyle(
color = GlanceTheme.colors.onSurfaceVariant,
@@ -258,6 +324,7 @@ class LocalStatsWidget : GlanceAppWidget() {
}
}
+ @Suppress("LongMethod")
@Composable
private fun Footer(state: LocalStatsWidgetUiState) {
Column(modifier = GlanceModifier.fillMaxWidth()) {
@@ -267,11 +334,11 @@ class LocalStatsWidget : GlanceAppWidget() {
) {
Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.Start) {
Text(
- text = state.nodesLabel,
+ text = stringResource(Res.string.nodes),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp),
)
Text(
- text = state.nodeCountText,
+ text = "${state.onlineNodes}/${state.totalNodes}",
maxLines = 1,
style =
TextStyle(
@@ -283,11 +350,11 @@ class LocalStatsWidget : GlanceAppWidget() {
}
Column(modifier = GlanceModifier.defaultWeight(), horizontalAlignment = Alignment.End) {
Text(
- text = state.uptimeLabel,
+ text = stringResource(Res.string.uptime),
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp),
)
Text(
- text = state.uptimeText,
+ text = formatUptime(state.uptimeSecs.toInt()),
maxLines = 1,
style =
TextStyle(
@@ -299,11 +366,17 @@ class LocalStatsWidget : GlanceAppWidget() {
}
}
Row(modifier = GlanceModifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ val updatedLabel = stringResource(Res.string.updated)
+ val updatedText =
+ stringResource(
+ Res.string.local_stats_updated_at,
+ DateFormatter.formatShortDate(state.updateTimeMillis),
+ )
val footerText =
- if (state.updatedLabel.isNotEmpty()) {
- "${state.updatedLabel} ${state.updatedText}"
+ if (updatedLabel.isNotEmpty()) {
+ "$updatedLabel $updatedText"
} else {
- state.updatedText
+ updatedText
}
Text(
text = footerText,
@@ -386,27 +459,24 @@ class LocalStatsWidget : GlanceAppWidget() {
}
}
-internal suspend fun createMockWidgetState() = LocalStatsWidgetUiState(
+internal fun createMockWidgetState() = LocalStatsWidgetUiState(
connectionState = ConnectionState.Connected,
showContent = true,
- appName = getStringSuspend(Res.string.meshtastic_app_name),
- nodesLabel = getStringSuspend(Res.string.nodes),
- uptimeLabel = getStringSuspend(Res.string.uptime),
- updatedLabel = getStringSuspend(Res.string.updated),
- refreshLabel = getStringSuspend(Res.string.refresh),
nodeShortName = "ME",
nodeColors = 0xFFFFFFFF.toInt() to 0xFF000000.toInt(),
- batteryLabel = getStringSuspend(Res.string.battery),
- batteryValue = "85%",
+ batteryLevel = 85,
+ hasBattery = true,
batteryProgress = 0.85f,
- channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization),
- channelUtilizationValue = "18.5%",
+ channelUtilization = 18.5f,
channelUtilizationProgress = 0.185f,
- airUtilizationLabel = getStringSuspend(Res.string.air_utilization),
- airUtilizationValue = "3.2%",
+ airUtilization = 3.2f,
airUtilizationProgress = 0.032f,
- trafficText = "TX: 145 | RX: 892 | D: 42",
- nodeCountText = "2/3",
- uptimeText = "2d 0h",
- updatedText = "5m ago",
+ hasStats = true,
+ numPacketsTx = 145,
+ numPacketsRx = 892,
+ numRxDupe = 42,
+ totalNodes = 3,
+ onlineNodes = 2,
+ uptimeSecs = 172800L,
+ updateTimeMillis = System.currentTimeMillis() - 300000L,
)
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
index 7d6dea60b..75dc02cd1 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
@@ -27,35 +27,10 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
-import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.onlineTimeThreshold
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.air_utilization
-import org.meshtastic.core.resources.battery
-import org.meshtastic.core.resources.channel_utilization
-import org.meshtastic.core.resources.connecting
-import org.meshtastic.core.resources.device_sleeping
-import org.meshtastic.core.resources.disconnected
-import org.meshtastic.core.resources.getStringSuspend
-import org.meshtastic.core.resources.local_stats_bad
-import org.meshtastic.core.resources.local_stats_diagnostics_prefix
-import org.meshtastic.core.resources.local_stats_dropped
-import org.meshtastic.core.resources.local_stats_heap
-import org.meshtastic.core.resources.local_stats_heap_value
-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_updated_at
-import org.meshtastic.core.resources.meshtastic_app_name
-import org.meshtastic.core.resources.nodes
-import org.meshtastic.core.resources.powered
-import org.meshtastic.core.resources.refresh
-import org.meshtastic.core.resources.updated
-import org.meshtastic.core.resources.uptime
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.LocalStats
@@ -64,48 +39,42 @@ import javax.inject.Singleton
data class LocalStatsWidgetUiState(
val connectionState: ConnectionState = ConnectionState.Disconnected,
- // Rendering data
- val statusText: String = "",
val isConnecting: Boolean = false,
val showContent: Boolean = false,
- // Static Strings (Resolved in provider for Glance stability)
- val appName: String = "",
- val nodesLabel: String = "",
- val uptimeLabel: String = "",
- val updatedLabel: String = "",
- val refreshLabel: String = "",
-
// Node Identity
val nodeShortName: String? = null,
val nodeColors: Pair? = null,
// Battery
- val batteryLabel: String = "",
- val batteryValue: String = "",
+ val batteryLevel: Int = 0,
+ val hasBattery: Boolean = false,
val batteryProgress: Float = 0f,
// Utilization
- val channelUtilizationLabel: String = "",
- val channelUtilizationValue: String = "",
+ val channelUtilization: Float = 0f,
val channelUtilizationProgress: Float = 0f,
- val airUtilizationLabel: String = "",
- val airUtilizationValue: String = "",
+ val airUtilization: Float = 0f,
val airUtilizationProgress: Float = 0f,
- // Packet Stats Lines
- val trafficText: String? = null,
- val relayText: String? = null,
- val diagnosticsText: String? = null,
+ // Stats
+ val hasStats: Boolean = false,
+ val numPacketsTx: Int = 0,
+ val numPacketsRx: Int = 0,
+ val numRxDupe: Int = 0,
+ val numTxRelay: Int = 0,
+ val numTxRelayCanceled: Int = 0,
+ val noiseFloor: Int = 0,
+ val numPacketsRxBad: Int = 0,
+ val numTxDropped: Int = 0,
val heapFreeBytes: Int = 0,
val heapTotalBytes: Int = 0,
- val heapValue: String? = null,
- val heapText: String? = null,
// Footer
- val nodeCountText: String = "",
- val uptimeText: String = "",
- val updatedText: String = "",
+ val totalNodes: Int = 0,
+ val onlineNodes: Int = 0,
+ val uptimeSecs: Long = 0,
+ val updateTimeMillis: Long = 0,
)
@Singleton
@@ -151,101 +120,49 @@ constructor(
)
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
- private suspend fun mapToUiState(
+ private fun mapToUiState(
connectionState: ConnectionState,
totalNodes: Int,
onlineNodes: Int,
stats: LocalStats,
localNode: Node?,
): LocalStatsWidgetUiState {
- val statusText =
- when (connectionState) {
- is ConnectionState.Disconnected -> getStringSuspend(Res.string.disconnected)
- is ConnectionState.Connecting -> getStringSuspend(Res.string.connecting)
- is ConnectionState.DeviceSleep -> getStringSuspend(Res.string.device_sleeping)
- is ConnectionState.Connected -> ""
- }
-
val metrics = localNode?.deviceMetrics
val batteryLevel = metrics?.battery_level ?: 0
- val isPowered = batteryLevel > 100
- val batteryValue = if (isPowered) getStringSuspend(Res.string.powered) else "$batteryLevel%"
val hasStats = stats.uptime_seconds != 0
val channelUtil = if (hasStats) stats.channel_utilization else metrics?.channel_utilization ?: 0f
val airUtilTx = if (hasStats) stats.air_util_tx else metrics?.air_util_tx ?: 0f
-
- val diag = mutableListOf()
- if (hasStats) {
- if (stats.noise_floor != 0) {
- diag.add(getStringSuspend(Res.string.local_stats_noise, stats.noise_floor))
- }
- if (stats.num_packets_rx_bad > 0) {
- diag.add(getStringSuspend(Res.string.local_stats_bad, stats.num_packets_rx_bad))
- }
- if (stats.num_tx_dropped > 0) {
- diag.add(getStringSuspend(Res.string.local_stats_dropped, stats.num_tx_dropped))
- }
- }
-
val uptimeSecs = if (hasStats) stats.uptime_seconds.toLong() else metrics?.uptime_seconds?.toLong() ?: 0L
return LocalStatsWidgetUiState(
connectionState = connectionState,
- statusText = statusText,
isConnecting = connectionState is ConnectionState.Connecting,
showContent = connectionState is ConnectionState.Connected,
- appName = getStringSuspend(Res.string.meshtastic_app_name),
- nodesLabel = getStringSuspend(Res.string.nodes),
- uptimeLabel = getStringSuspend(Res.string.uptime),
- updatedLabel = getStringSuspend(Res.string.updated),
- refreshLabel = getStringSuspend(Res.string.refresh),
nodeShortName = localNode?.user?.short_name,
nodeColors = localNode?.colors,
- batteryLabel = getStringSuspend(Res.string.battery),
- batteryValue = batteryValue,
+ batteryLevel = batteryLevel,
+ hasBattery = metrics?.battery_level != null,
batteryProgress = (batteryLevel / 100f).coerceIn(0f, 1f),
- channelUtilizationLabel = getStringSuspend(Res.string.channel_utilization),
- channelUtilizationValue = "%.1f%%".format(channelUtil),
+ channelUtilization = channelUtil,
channelUtilizationProgress = (channelUtil / 100f).coerceIn(0f, 1f),
- airUtilizationLabel = getStringSuspend(Res.string.air_utilization),
- airUtilizationValue = "%.1f%%".format(airUtilTx),
+ airUtilization = airUtilTx,
airUtilizationProgress = (airUtilTx / 100f).coerceIn(0f, 1f),
- trafficText =
- if (hasStats) {
- getStringSuspend(
- Res.string.local_stats_traffic,
- stats.num_packets_tx,
- stats.num_packets_rx,
- stats.num_rx_dupe,
- )
- } else {
- null
- },
- relayText =
- stats
- .takeIf { hasStats && (it.num_tx_relay > 0 || it.num_tx_relay_canceled > 0) }
- ?.let {
- getStringSuspend(Res.string.local_stats_relays, it.num_tx_relay, it.num_tx_relay_canceled)
- },
- diagnosticsText =
- if (diag.isNotEmpty()) {
- getStringSuspend(Res.string.local_stats_diagnostics_prefix, diag.joinToString(" | "))
- } else {
- null
- },
- heapFreeBytes = if (hasStats) stats.heap_free_bytes else 0,
- heapTotalBytes = if (hasStats) stats.heap_total_bytes else 0,
- heapValue =
- if (hasStats) {
- getStringSuspend(Res.string.local_stats_heap_value, stats.heap_free_bytes, stats.heap_total_bytes)
- } else {
- null
- },
- heapText = if (hasStats) getStringSuspend(Res.string.local_stats_heap) else null,
- nodeCountText = "$onlineNodes/$totalNodes",
- uptimeText = formatUptime(uptimeSecs.toInt()),
- updatedText = getStringSuspend(Res.string.local_stats_updated_at, DateFormatter.formatShortDate(nowMillis)),
+ hasStats = hasStats,
+ numPacketsTx = stats.num_packets_tx,
+ numPacketsRx = stats.num_packets_rx,
+ numRxDupe = stats.num_rx_dupe,
+ numTxRelay = stats.num_tx_relay,
+ numTxRelayCanceled = stats.num_tx_relay_canceled,
+ noiseFloor = stats.noise_floor,
+ numPacketsRxBad = stats.num_packets_rx_bad,
+ numTxDropped = stats.num_tx_dropped,
+ heapFreeBytes = stats.heap_free_bytes,
+ heapTotalBytes = stats.heap_total_bytes,
+ totalNodes = totalNodes,
+ onlineNodes = onlineNodes,
+ uptimeSecs = uptimeSecs,
+ updateTimeMillis = nowMillis,
)
}
}
diff --git a/core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.kt b/core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.kt
index 0cc16e66c..abeef17a0 100644
--- a/core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.kt
+++ b/core/network/src/google/kotlin/org/meshtastic/core/network/di/GoogleNetworkModule.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.network.di
import android.content.Context
@@ -26,11 +25,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
-import io.ktor.client.HttpClient
-import io.ktor.client.engine.okhttp.OkHttp
-import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
-import io.ktor.serialization.kotlinx.json.json
-import kotlinx.serialization.json.Json
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@@ -71,20 +65,5 @@ interface GoogleNetworkModule {
)
.eventListenerFactory(eventListenerFactory = DatadogEventListener.Factory())
.build()
-
- @Provides
- @Singleton
- fun provideHttpClient(okHttpClient: OkHttpClient): HttpClient = HttpClient(engineFactory = OkHttp) {
- engine { preconfigured = okHttpClient }
-
- install(plugin = ContentNegotiation) {
- json(
- Json {
- isLenient = true
- ignoreUnknownKeys = true
- },
- )
- }
- }
}
}
diff --git a/core/network/src/main/kotlin/org/meshtastic/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/org/meshtastic/core/network/di/NetworkModule.kt
index c866857ef..354487614 100644
--- a/core/network/src/main/kotlin/org/meshtastic/core/network/di/NetworkModule.kt
+++ b/core/network/src/main/kotlin/org/meshtastic/core/network/di/NetworkModule.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.network.di
import android.content.Context
@@ -31,6 +30,11 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import org.meshtastic.core.network.BuildConfig
import javax.inject.Singleton
@@ -49,7 +53,7 @@ class NetworkModule {
return ImageLoader.Builder(context = application)
.components {
add(OkHttpNetworkFetcherFactory(callFactory = { sharedOkHttp }))
- add(SvgDecoder.Factory())
+ add(SvgDecoder.Factory(scaleToDensity = true))
}
.memoryCache {
MemoryCache.Builder().maxSizePercent(context = application, percent = MEMORY_CACHE_PERCENT).build()
@@ -59,4 +63,19 @@ class NetworkModule {
.crossfade(enable = true)
.build()
}
+
+ @Provides
+ @Singleton
+ fun provideHttpClient(okHttpClient: OkHttpClient): HttpClient = HttpClient(engineFactory = OkHttp) {
+ engine { preconfigured = okHttpClient }
+
+ install(plugin = ContentNegotiation) {
+ json(
+ Json {
+ isLenient = true
+ ignoreUnknownKeys = true
+ },
+ )
+ }
+ }
}
diff --git a/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt b/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt
index 0b95a5a79..ad3f4c9a2 100644
--- a/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt
+++ b/core/resources/src/androidMain/kotlin/org/meshtastic/core/resources/ContextExt.kt
@@ -25,7 +25,7 @@ fun getString(stringResource: StringResource): String = runBlocking { composeGet
/** Retrieves a formatted string from the [StringResource] in a blocking manner. */
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
- getStringSuspend(stringResource, *formatArgs)
+ composeGetString(stringResource, *formatArgs)
}
/** Retrieves a string from the [StringResource] in a suspending manner. */
@@ -37,7 +37,6 @@ suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs:
formatArgs
.map { arg ->
if (arg is StringResource) {
- // Resolve nested StringResources recursively
getStringSuspend(arg)
} else {
arg
@@ -45,9 +44,6 @@ suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs:
}
.toTypedArray()
- // Compose Multiplatform doesn't fully support complex formatting like %.2f
- // Fetch the raw string and format it using standard Java String.format.
- val rawString = composeGetString(stringResource)
@Suppress("SpreadOperator")
- return String.format(java.util.Locale.getDefault(), rawString, *resolvedArgs)
+ return composeGetString(stringResource, *resolvedArgs)
}
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt
index 6c300f4a9..babb05fb3 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ContextExtensions.kt
@@ -25,12 +25,8 @@ import android.widget.Toast
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
-suspend fun Context.showToast(stringResource: StringResource) {
- showToast(getString(stringResource))
-}
-
suspend fun Context.showToast(stringResource: StringResource, vararg formatArgs: Any) {
- Toast.makeText(this, getString(stringResource, formatArgs), Toast.LENGTH_SHORT).show()
+ Toast.makeText(this, getString(stringResource, *formatArgs), Toast.LENGTH_SHORT).show()
}
suspend fun Context.showToast(text: String) {
diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts
index db489e53c..265d4334c 100644
--- a/feature/firmware/build.gradle.kts
+++ b/feature/firmware/build.gradle.kts
@@ -33,6 +33,7 @@ dependencies {
implementation(projects.core.datastore)
implementation(projects.core.model)
implementation(projects.core.navigation)
+ implementation(projects.core.network)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
@@ -48,6 +49,7 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.kermit)
+ implementation(libs.ktor.client.core)
implementation(libs.nordic.client.android)
implementation(libs.nordic.dfu)
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt
index 981d7e5cc..75985a0ed 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt
@@ -20,12 +20,17 @@ import android.content.Context
import android.net.Uri
import co.touchlab.kermit.Logger
import dagger.hilt.android.qualifiers.ApplicationContext
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.request.head
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.http.contentLength
+import io.ktor.http.isSuccess
+import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
-import okhttp3.OkHttpClient
-import okhttp3.Request
import org.meshtastic.core.model.DeviceHardware
import java.io.File
import java.io.FileInputStream
@@ -45,7 +50,7 @@ class FirmwareFileHandler
@Inject
constructor(
@ApplicationContext private val context: Context,
- private val client: OkHttpClient,
+ private val client: HttpClient,
) {
private val tempDir = File(context.cacheDir, "firmware_update")
@@ -60,10 +65,9 @@ constructor(
}
suspend fun checkUrlExists(url: String): Boolean = withContext(Dispatchers.IO) {
- val request = Request.Builder().url(url).head().build()
try {
- client.newCall(request).execute().use { response -> response.isSuccessful }
- } catch (e: IOException) {
+ client.head(url).status.isSuccess()
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "Failed to check URL existence: $url" }
false
}
@@ -71,28 +75,27 @@ constructor(
suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): File? =
withContext(Dispatchers.IO) {
- val request = Request.Builder().url(url).build()
val response =
try {
- client.newCall(request).execute()
- } catch (e: IOException) {
+ client.get(url)
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.w(e) { "Download failed for $url" }
return@withContext null
}
- if (!response.isSuccessful) {
- Logger.w { "Download failed: ${response.code} for $url" }
+ if (!response.status.isSuccess()) {
+ Logger.w { "Download failed: ${response.status.value} for $url" }
return@withContext null
}
- val body = response.body ?: return@withContext null
- val contentLength = body.contentLength()
+ val body = response.bodyAsChannel()
+ val contentLength = response.contentLength() ?: -1L
if (!tempDir.exists()) tempDir.mkdirs()
val targetFile = File(tempDir, fileName)
- body.byteStream().use { input ->
+ body.toInputStream().use { input ->
FileOutputStream(targetFile).use { output ->
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
var bytesRead: Int
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
index 08789a2ba..64f31d832 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
@@ -18,7 +18,6 @@ package org.meshtastic.feature.map.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.key
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -32,6 +31,7 @@ import com.google.maps.android.clustering.view.DefaultClusterRenderer
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
+import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.feature.map.model.NodeClusterItem
@@ -63,10 +63,19 @@ fun NodeClusterMarkers(
}
}
- if (mapFilterState.showPrecisionCircle) {
- nodeClusterItems.forEach { clusterItem ->
- key(clusterItem.node.num) {
- // Add a stable key for each circle
+ Clustering(
+ items = nodeClusterItems,
+ onClusterClick = onClusterClick,
+ onClusterItemInfoWindowClick = { item ->
+ navigateToNodeDetails(item.node.num)
+ false
+ },
+ clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) },
+ onClusterManager = { clusterManager ->
+ (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10
+ },
+ clusterItemDecoration = { clusterItem ->
+ if (mapFilterState.showPrecisionCircle) {
clusterItem.getPrecisionMeters()?.let { precisionMeters ->
if (precisionMeters > 0) {
Circle(
@@ -80,18 +89,7 @@ fun NodeClusterMarkers(
}
}
}
- }
- }
- Clustering(
- items = nodeClusterItems,
- onClusterClick = onClusterClick,
- onClusterItemInfoWindowClick = { item ->
- navigateToNodeDetails(item.node.num)
- false
- },
- clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) },
- onClusterManager = { clusterManager ->
- (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10
+ ClusteringMarkerProperties(zIndex = 1f)
},
)
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt
index 7a6278169..ea91f78fe 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt
@@ -81,11 +81,14 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.format
+import kotlinx.datetime.format.char
+import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.toDate
-import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_clear
import org.meshtastic.core.resources.debug_decoded_payload
@@ -113,8 +116,7 @@ import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog
import java.io.IOException
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
-import java.text.SimpleDateFormat
-import java.util.Locale
+import kotlin.time.Instant.Companion.fromEpochMilliseconds
private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE)
@@ -201,8 +203,18 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo
filterMode = filterMode,
onFilterModeChange = { filterMode = it },
onExportLogs = {
+ val format =
+ LocalDateTime.Format {
+ year()
+ monthNumber()
+ day()
+ char('_')
+ hour()
+ minute()
+ second()
+ }
val timestamp =
- SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
+ fromEpochMilliseconds(nowMillis).toLocalDateTime(TimeZone.UTC).format(format)
val fileName = "meshtastic_debug_$timestamp.txt"
exportLogsLauncher.launch(fileName)
},
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 97b07a05e..737094bca 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -28,7 +28,7 @@ mockk = "1.14.9"
testRetry = "1.6.4"
# Compose Multiplatform
-compose-multiplatform = "1.10.1"
+compose-multiplatform = "1.11.0-alpha03"
# Google
hilt = "2.59.2"
@@ -165,6 +165,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
# Networking
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version = "5.3.2" }
From 27e7669366a6dbe810e988f346b7563397047c9c Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 26 Feb 2026 08:57:10 -0600
Subject: [PATCH 021/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4654)
---
app/src/main/assets/firmware_releases.json | 6 --
.../composeResources/values-et/strings.xml | 8 +++
.../composeResources/values-ja/strings.xml | 2 +
.../composeResources/values-ru/strings.xml | 49 ++++++++++++++
.../values-zh-rCN/strings.xml | 65 +++++++++++++++++++
.../values-zh-rTW/strings.xml | 49 ++++++++++++--
.../android/ja-JP/full_description.txt | 13 ++--
.../android/zh-CN/changelogs/default.txt | 2 +-
.../android/zh-CN/full_description.txt | 18 ++---
.../android/zh-TW/short_description.txt | 2 +-
feature/firmware/README.md | 1 +
11 files changed, 187 insertions(+), 28 deletions(-)
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 432afbed7..3ef09fbba 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -211,12 +211,6 @@
"title": "Add phone API fanout for multiple clients",
"page_url": "https://github.com/meshtastic/firmware/pull/9627",
"zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9586",
- "title": "feat(esp32): add support for Native ESP32 Ethernet and WT32-ETH01 variant",
- "page_url": "https://github.com/meshtastic/firmware/pull/9586",
- "zip_url": "https://discord.com/invite/meshtastic"
}
]
}
\ No newline at end of file
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index 10c480f27..6a7468e0d 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -1147,9 +1147,17 @@
Sõlmed: %1$d võrgus / %2$d kokku
Töös: %1$s
ChUtil: %1$.2f%% | AirTX: %2$.2f%%
+ Liiklus: TX %1$d / RX %2$d (D: %3$d)
Vahendatud: %1$d (Tühistatud: %2$d)
Diagnostika: %1$s
Müra %1$d dBm
Paha %1$d
Kukkunud %1$d
+ Lasu
+ %1$d / %2$d
+ %1$s
+ Toitega
+ Meshtasticu statistika
+ Värskenda
+ Uuendatud
diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
index 246a0cdcd..565b82def 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -32,6 +32,7 @@
最後の通信
MQTT経由
MQTT経由
+ UDP 経由
お気に入り経由
不明
相手の受信確認待ち
@@ -75,6 +76,7 @@
加速度センサー搭載デバイスで本体をダブルタップすると、ボタンのプッシュと同じ動作として扱います。
デバイスの点滅するLEDを制御します。ほとんどのデバイスでは、最大4つあるLEDのうちの1つを制御します。充電用LEDとGPS用LEDは制御できません。
近隣ノード情報(NeighborInfo)をMQTTやPhoneAPIへ送信することに加えて、LoRa無線経由でも送信すべきかどうかを設定します。デフォルトの名前とキーが設定されたチャンネルでは利用できません。
+ UDP 経由でローカルネットワーク上のパケットのブロードキャスト通信を有効にする。
GPIO
デバッグ
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index c830d773b..b052fa58a 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -542,6 +542,7 @@
Трансляция состояния (в секундах)
Отправить колокол с уведомлением
Понятное имя
+ Дружеское обращение
GPIO контакт для мониторинга
Тип триггера обнаружения
Использовать режим INPUT_PULLUP
@@ -630,6 +631,7 @@
WiFi включен
Название сети
Пароль
+ Получить документ
Настройки Ethernet
Ethernet включен
NTP-сервер
@@ -878,6 +880,7 @@
PAX
Метрики прохожих недоступны
WiFi устройства
+ Устройства Bluetooth
Сопряженные устройства
Подключённые устройства
Превышен лимит запросов. Пожалуйста, повторите попытку позже.
@@ -890,6 +893,7 @@
Версия прошивки
Недавние сетевые устройства
Найденные сетевые устройства
+ Доступные Bluetooth-устройства
Начать работу
Добро пожаловать в
Оставайтесь на связи везде
@@ -986,6 +990,12 @@
Дополнительная информация доступна в нашей политике конфиденциальности.
Не задано - 0
Ретранслировано: %1$s
+
+ - Услышано %1$d ретранслятором
+ - Услышано %1$d ретрансляторами
+ - Услышано %1$d ретрансляторами
+ - Услышано %1$d ретрансляторами
+
%1$s обычно поставляется с загрузчиком, который не поддерживает OTA обновления. Может потребоваться прошивка OTA-совместимого загрузчика по USB перед прошивкой OTA.
Подробности
Для RAK WisBlock RAK4631, используйте прошивальщик от производителя (например, adafruit-nrfutil с предоставленным .zip файлом загрузчика). Копирование файла .uf2 само по себе не обновит загрузчик.
@@ -1033,6 +1043,7 @@
Щебетун
Перезагрузка в DFU...
Ожидание DFU устройства...
+ Дай пять! Подожди, идет копирование прошивки...
Пожалуйста, сохраните файл \".uf2\" на вашем устройстве с DFU.
Прошивка устройства, подождите...
Передача файлов через USB
@@ -1071,6 +1082,18 @@
Назад
Не установлена
Всегда включено
+
+ - %1$d секунда
+ - %1$d секунды
+ - %1$d секунд
+ - %1$d секунд
+
+
+ - %1$d минута
+ - %1$d минуты
+ - %1$d минут
+ - %1$d минут
+
- %1$d час
- %1$d часов
@@ -1126,5 +1149,31 @@
NFC отключен. Пожалуйста, включите его в настройках вашего устройства.
Всё
Bluetooth
+ Настроить разрешения Bluetooth
+ Подключиться к радио
+ Просканируйте и подключитесь к вашей радиостанции Meshtastic.
+ Обнаружение
+ Найдите и определите устройства Meshtastic рядом с вами.
Настройки
+ Беспроводное управление настройками устройства и каналами.
+ Разрешение получено
+ Доступ запрещён
+ Выбор стиля карты
+ Батарея: %1$d%%
+ Нод: %1$d онлайн / %2$d всего
+ Время работы: %1$s
+ ChUtil: %1$.2f%% | AirTX: %2$.2f%%
+ Traffic: TX %1$d / RX %2$d (D: %3$d)
+ Передано: %1$d (Отменено: %2$d)
+ Диагностика: %1$s
+ Шум: %1$d дБм
+ Плохие: %1$d
+ Отброшено: %1$d
+ Куча
+ %1$d / %2$d
+ %1$s
+ Питание
+ Статистика Meshtastic
+ Обновить
+ Обновлено
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index 1245ce727..d89f6a908 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -37,6 +37,9 @@
上次连接时间
通过 MQTT
通过 MQTT
+ UDP
+ API
+ 内置
通过收藏夹
仅显示忽略的节点
无法识别的
@@ -147,15 +150,19 @@
位置数据包
广播间隔
智能位置
+ 自动时间间隔
+ 自动距离大小
设备 GPS
固定位置
海拔
+ GPS 轮询间隔
高级设备 GPS
GPS 接收GPIO
GPS 输出 GPIO
GPS 使能 GPIO
GPIO
调试
+ Ch
频道名称
QR 码
未知的使用者名称
@@ -256,6 +263,7 @@
系统默认设置
选择主题
向网格提供手机位置
+ 紧凑的Cyrillic编码
- 删除消息?
@@ -354,6 +362,12 @@
WiFi凭据二维码格式无效
回溯导航
电池
+ ChUtil
+ AirUtil
+ 温度
+ 湿度
+ 土壤温度
+ 土壤湿度
日志
跃点数
越点数: %1$d
@@ -406,11 +420,13 @@
%1$s - %2$s
路由追踪到目的地:\n\n
路由回退到当前节点:\n\n
+ 1H
24 小时
48 小时
1 周
2 周
4 周
+ 1M
最大值
未知时长
复制
@@ -434,6 +450,7 @@
低电量通知
电池电量低: %1$s
低电量通知 (收藏节点)
+ Baro
启用
UDP 广播
UDP 设置
@@ -515,6 +532,7 @@
状态广播(秒)
发送带有警报消息的响铃声
易记名称
+ 友好地址
显示器的 GPIO 引脚
检测触发器类型
使用 输入上拉 模式
@@ -603,6 +621,7 @@
启用 WiFi
SSID
共享密钥/PSK
+ 获取文档
以太网选项
启用以太网
NTP 服务器
@@ -610,6 +629,7 @@
IPv4模式
IP
网关
+ 子版块
Paxcount 配置
启用 Paxcount
状态消息
@@ -850,6 +870,7 @@
PAX
无可用的 PAX 计量.
WiFi 设备
+ 蓝牙设备
已配对设备
已连设备
超过速率限制。请稍后再试。
@@ -862,6 +883,7 @@
固件版本
最近使用的网络设备
发现的网络设备
+ 可用的蓝牙设备
开始
欢迎使用
随时随地保持联系
@@ -877,6 +899,7 @@
新发现节点通知。
电池电量低
已连接设备的低电量警报通知。
+ 选择按关键值发送的数据包将忽略msg开关和“请勿扰”系统通知中心中的设置。
配置通知权限
手机位置
Meshtastic 通过使用您的手机定位功能来实现多项功能。您可随时通过设置菜单调整定位权限。
@@ -952,9 +975,13 @@
系统设置
没有可用的统计信息
我们收集分析数据是为了帮助改进这款安卓应用(感谢您的支持),我们会收到关于用户行为的匿名信息。这包括崩溃报告、应用中使用过的屏幕等内容。
+ 分析平台:
欲了解更多信息,请参阅我们的隐私政策。
未设定 - 0
由: %1$s
+
+
- 连接到的 %1$d 中继节点
+
%1$s通常配备的引导加载程序不支持OTA更新。在进行OTA刷写之前,您可能需要通过USB刷写一个支持OTA的引导加载程序。
更多信息
对于RAK WisBlock RAK4631,请使用供应商的串行DFU工具(例如,搭配提供的引导加载程序.zip文件使用adafruit-nrfutil dfu serial)。仅复制.uf2文件无法更新引导加载程序。
@@ -1008,6 +1035,7 @@
Chirpy
正在重启到 DFU……
正在等待 DFU 设备...
+ 请稍候,正在复制固件…
请将 .uf2 文件保存到您的设备's DFU 驱动器。
正在刷入设备,请稍候...
USB文件传输
@@ -1090,8 +1118,45 @@
已过滤
启用过滤
禁用过滤
+ 频道URL
+ NFC扫描
+ 扫描共享联系人NFC
+ 扫描共享联系人二维码
+ 输入共享联系人 URL
+ 扫描频道 NFC
+ 扫描频道二维码
+ 输入频道 URL
+ 共享频道二维码
+ 将您的设备靠近NFC标签
生成二维码
+ NFC 已禁用,请在系统设置中启用它。
全部
蓝牙
+ 设置蓝牙权限
+ 连接无线电
+ 扫描并连接到您的Meshtastic无线电设备
+ 发现
+ 查找并识别附近的Meshtastic设备
配置
+ 无线的方式来管理您的设备设置和频道
+ 权限已授予
+ 权限不足
+ 地图样式选择
+ 电量: %1$d%%
+ 节点: %1$d 在线 / %2$d 总计
+ 运行时间: %1$s
+ ChUtil: %1$.2f%% | AirTX: %2$.2f%%
+ 流量:TX %1$d / RX %2$d (D: %3$d)
+ 转发: %1$d (取消: %2$d)
+ 诊断: %1$s
+ 底噪 %1$d dBm
+ 错误 %1$d
+ 丢弃 %1$d
+ 空闲
+ %1$d / %2$d
+ %1$s
+ 支持
+ Meshtastic 统计
+ 刷新
+ 更新
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index e2eee7314..9683f22dd 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -37,6 +37,9 @@
最近一次收到排序
有節點MQTT排序
有節點MQTT排序
+ 透過 UDP
+ 透過 API
+ 內部傳輸
通過喜好
僅顯示已忽略的節點
無法識別
@@ -145,7 +148,7 @@
透過序列埠輸出即時除錯日誌;透過藍牙檢視並匯出已移除定位資訊的設備記錄。
定位封包
- Broadcast Interval
+ 廣播間隔
智慧定位
智慧間隔
智慧距離
@@ -179,12 +182,12 @@
報告
配對完成,開始服務
配對失敗,請重新選擇
- 位置訪問已關閉,無法向設備提供位置.
+ 定位服務已關閉,無法向設備提供位置。
分享
發現新節點: %1$s
已中斷連線
設備休眠中
- 已連接:%1$s 在線
+ 已連接:線上 %1$s
IP地址:
Ip_ 埠:
已連線
@@ -195,7 +198,7 @@
正在連線
未連線
未選擇裝置
- 已連接至無線電,但它正在休眠中
+ 已連接裝置,但該裝置正在休眠中
需要應用程式更新
您必須在應用商店(或 Github)更新此應用程式。它太舊無法與此無線電韌體通訊。請閱讀我們關於此主題的文件。
無(停用)
@@ -203,7 +206,7 @@
致謝
此頻道 URL 無效,無法使用
此聯絡人無效,無法新增
- 除錯面板
+ 偵錯面板
解析封包:
匯出日誌
已取消匯出
@@ -527,6 +530,7 @@
狀態廣播間隔 (秒)
告警訊息發送提示音
顯示名稱
+ 友善地址
螢幕的 GPIO 腳位
偵測觸發類型
使用輸入上拉模式
@@ -615,6 +619,7 @@
啟用WiFi
SSID
PSK
+ 取得文件
乙太網路選項
啟用以太網
時間伺服器
@@ -810,6 +815,7 @@
模組已解鎖
模組已解鎖
遠端
+ (線上 %1$d / 顯示 %2$d / 總數 %3$d)
回應
中斷連線
找不到網路裝置。
@@ -862,6 +868,7 @@
PAX
無可用的 PAX 人流計量資料。
WiFi 裝置
+ 藍牙裝置
已配對的裝置
連接裝置
超過速率限制,請稍後再嘗試。
@@ -874,6 +881,7 @@
韌體版本
最近的網路裝置
發現的網路裝置
+ 可連接的藍牙裝置
開始使用
歡迎來到
隨時隨地保持連線
@@ -970,6 +978,9 @@
欲了解更多資訊,請查閱我們的隱私權政策。
預設值 - 0
經由:%1$s
+
+ - 聽到 %1$d 個中繼
+
%1$s 裝置出廠時預載的開機載入程式通常不支援 OTA 更新功能。在執行 OTA 韌體更新前,您可能需要先透過 USB 連線刷入具備 OTA 功能的開機載入程式。
瞭解詳情
針對 RAK WisBlock RAK4631 裝置,必須使用 ' 提供的序列埠 DFU(裝置韌體更新)工具進行更新。舉例來說,可以使用 adafruit-nrfutil dfu serial 命令配合提供的 bootloader .zip 壓縮檔。注意:單純複製 .uf2 檔案並不會更新開機載入程式。
@@ -1017,6 +1028,7 @@
Chirpy
正在進入 DFU 模式...
等待裝置進入 DFU 模式...
+ 正在複製韌體⋯⋯記得要強調是史上最快喔!
請將 .uf2 檔案儲存到您 ' 裝置 DFU 磁碟機。
刷入韌體中,請稍等...
USB 檔案傳輸
@@ -1094,6 +1106,7 @@
正規表示式
完整字詞比對
已篩選 %1$d 則
+ 顯示 %1$d 個已篩選
隱藏已篩選 %1$d 則
已篩選
啟用篩選
@@ -1112,5 +1125,31 @@
NFC 已停用,請在系統設定中啟用。
全部
藍牙
+ 設定藍牙權限
+ 連線至無線電
+ 掃描並連線至你的 Meshtastic 網狀無線電裝置。
+ 探索
+ 尋找並識別附近的 Meshtastic 裝置。
設定
+ 無線管理你的裝置設定與頻道。
+ 已授予權限
+ 已拒絕權限
+ 地圖樣式選擇
+ 電量:%1$d%%
+ 線上 %1$d / 總計 %2$d
+ 上線時間: %1$s
+ 頻道使用率: %1$.2f% | 空中傳輸佔用率: %2$.2f%
+ 流量: 傳送 %1$d / 接收 %2$d (丟棄: %3$d)
+ 中繼: %1$d (取消: %2$d)
+ 診斷: %1$s
+ 雜訊 %1$d dBm
+ 錯誤 %1$d
+ 已丟棄 %1$d
+ 堆積記憶體
+ %1$d / %2$d
+ %1$s
+ 已供電
+ Meshtastic 統計
+ 重新整理
+ 已更新
diff --git a/fastlane/metadata/android/ja-JP/full_description.txt b/fastlane/metadata/android/ja-JP/full_description.txt
index 4ade9c536..a3e9cf99a 100644
--- a/fastlane/metadata/android/ja-JP/full_description.txt
+++ b/fastlane/metadata/android/ja-JP/full_description.txt
@@ -1,10 +1,11 @@
-Meshtastic is a tool for using Android devices with open-source, off-grid mesh radios. This app is the main client for the Meshtastic project, allowing you to manage your mesh devices and communicate with other users.
+Meshtasticは、オープンソースのオフグリッドメッシュ無線とAndroidデバイスを連携させるためのソフトです。 このアプリは Meshtastic プロジェクトのメインクライアントであり、メッシュデバイスの管理や他のユーザーとの通信を可能にします。
-For more information about the Meshtastic project, please visit our website: meshtastic.org. The firmware that runs on the radio devices is a separate open-source project, which you can find here: https://github.com/meshtastic/Meshtastic-device.
+Meshtastic プロジェクトの詳細については、ぜひ公式ウェブサイトをご覧ください:meshtastic.org。 無線デバイスのファームウェアは別のオープンソースプロジェクトであり、詳しくはこちらをご覧ください:
+https://github.com/meshtastic/Meshtastic-device。
-Community and Support
+コミュニティとサポート
-This project is currently in beta. We would love to hear from you! If you have questions, feedback, or encounter any problems, please join our friendly and active community:
+当プロジェクトは、現時点でベータ版です。 皆様からのフィードバックをお待ちしております! If you have questions, feedback, or encounter any problems, please join our friendly and active community:
• Discussion Forum: https://github.com/orgs/meshtastic/discussions
• Discord: https://discord.gg/meshtastic
@@ -12,10 +13,10 @@ This project is currently in beta. We would love to hear from you! If you have q
Documentation
-To learn more about the features and capabilities of this app and Meshtastic, please view our official documentation:
+このアプリと Meshtastic の機能と性能について詳しく知りたい場合は、ぜひ公式ドキュメントをご覧ください:
View Documentation
-Translations
+翻訳
You can help translate the app into your native language using Crowdin:
https://crowdin.meshtastic.org/android
diff --git a/fastlane/metadata/android/zh-CN/changelogs/default.txt b/fastlane/metadata/android/zh-CN/changelogs/default.txt
index 0553de284..ddae0ffeb 100644
--- a/fastlane/metadata/android/zh-CN/changelogs/default.txt
+++ b/fastlane/metadata/android/zh-CN/changelogs/default.txt
@@ -1 +1 @@
-For detailed release notes, please visit: https://github.com/meshtastic/Meshtastic-Android/releases/
\ No newline at end of file
+欲了解详细发布说明,请访问:https://github.com/meshtastic/Meshtastic-Android/releases/
\ No newline at end of file
diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt
index 45ececd91..82a914fc9 100644
--- a/fastlane/metadata/android/zh-CN/full_description.txt
+++ b/fastlane/metadata/android/zh-CN/full_description.txt
@@ -1,21 +1,21 @@
Meshtastic 是一款将安卓设备与开源、无互联网、基于多跳网状形无线电配合使用的客户端程序。 此应用是Meshtastic的安卓客户端,允许您管理您的Meshtastic设备并与其他Meshtastic用户沟通。
-For more information about the Meshtastic project, please visit our website: meshtastic.org. The firmware that runs on the radio devices is a separate open-source project, which you can find here: https://github.com/meshtastic/Meshtastic-device.
+有关Meshtastic项目的更多信息,请访问我们的网站: meshtastic.org. 在无线电设备上运行的固件也是一个独立的开源项目,您可以在这里找到:https://github.com/meshtastic/Meshtastic-device.
-Community and Support
+社区和支持
-此项目目前处于测试阶段。 我们非常乐意听取您的建议和意见! 如果您有任何疑问,反馈或遇到任何问题,请加入我们友好和活跃的社区:
+此项目目前处于测试阶段, 我们非常乐意听取您的建议和意见! 如果您有任何疑问,反馈或遇到任何问题,请加入我们友好和活跃的社区:
-• Discussion Forum: https://github.com/orgs/meshtastic/discussions
+• 论坛: https://github.com/orgs/meshtastic/discussions
• Discord: https://discord.gg/meshtastic
-• Report an Issue: https://github.com/meshtastic/Meshtastic-Android/issues
+• 反馈: https://github.com/meshtastic/Meshtastic-Android/issues
-Documentation
+文档
要了解更多关于此应用和 Meshtastic的功能和功能,请查看我们的官方文档:
-View Documentation
+查看文档
-Translations
+翻译
-您可以使用 Crowdin 来帮助将应用程序翻译成您的母语:
+您可以使用 Crowdin 来帮助我们将应用程序翻译成您的母语:
https://crowdin.meshtastic.org/android
diff --git a/fastlane/metadata/android/zh-TW/short_description.txt b/fastlane/metadata/android/zh-TW/short_description.txt
index c2f2cbf5a..d29d3479c 100644
--- a/fastlane/metadata/android/zh-TW/short_description.txt
+++ b/fastlane/metadata/android/zh-TW/short_description.txt
@@ -1 +1 @@
-Meshtastic 官方應用程式,一款開源的離網網狀無線電系統。
\ No newline at end of file
+Meshtastic 官方應用程式——專為這款開源、離網式網狀無線電網路設計。
\ No newline at end of file
diff --git a/feature/firmware/README.md b/feature/firmware/README.md
index 19fcfa41a..1c811faf2 100644
--- a/feature/firmware/README.md
+++ b/feature/firmware/README.md
@@ -13,6 +13,7 @@ graph TB
:feature:firmware -.-> :core:datastore
:feature:firmware -.-> :core:model
:feature:firmware -.-> :core:navigation
+ :feature:firmware -.-> :core:network
:feature:firmware -.-> :core:prefs
:feature:firmware -.-> :core:proto
:feature:firmware -.-> :core:service
From b60d67297de7c37035698f323ec9c630267c1002 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 26 Feb 2026 09:39:15 -0600
Subject: [PATCH 022/474] fix(map): location perms and button visibility,
breadcrumb taps (#4651)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../org/meshtastic/feature/map/MapView.kt | 111 ++++++++++++++----
.../org/meshtastic/feature/map/MapView.kt | 111 ++++++++++--------
.../map/component/MapControlsOverlay.kt | 23 ++--
3 files changed, 160 insertions(+), 85 deletions(-)
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 7b968b53c..4130e57f3 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
@@ -66,6 +66,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
@@ -83,12 +84,13 @@ import kotlinx.coroutines.launch
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.common.util.nowSeconds
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
import org.meshtastic.core.resources.cancel
@@ -98,7 +100,10 @@ import org.meshtastic.core.resources.delete_for_everyone
import org.meshtastic.core.resources.delete_for_me
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.heading
+import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.location_disabled
+import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager
import org.meshtastic.core.resources.map_cache_size
@@ -116,6 +121,7 @@ import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.map_subDescription
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.only_favorites
+import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.resources.toggle_my_position
@@ -134,6 +140,7 @@ import org.meshtastic.feature.map.component.MapButton
import org.meshtastic.feature.map.model.CustomTileSource
import org.meshtastic.feature.map.model.MarkerWithLabel
import org.meshtastic.feature.map.model.TracerouteOverlay
+import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
@@ -166,12 +173,26 @@ import kotlin.math.sin
private fun MapView.updateMarkers(
nodeMarkers: List,
waypointMarkers: List,
+ trackMarkers: List,
+ trackPolylines: List,
nodeClusterer: RadiusMarkerClusterer,
) {
- Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" }
- overlays.removeAll { it is MarkerWithLabel }
- // overlays.addAll(nodeMarkers + waypointMarkers)
+ Logger.d {
+ "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints ${trackMarkers.size} tracks"
+ }
+
+ val trackOverlayIds = (trackMarkers + trackPolylines).toSet()
+
+ overlays.removeAll { overlay ->
+ overlay is MarkerWithLabel ||
+ (overlay is Marker && overlay !in nodeClusterer.items && overlay !in trackOverlayIds) ||
+ (overlay is Polyline && overlay !in trackOverlayIds)
+ }
+
overlays.addAll(waypointMarkers)
+ overlays.addAll(trackPolylines)
+ overlays.addAll(trackMarkers)
+
nodeClusterer.items.clear()
nodeClusterer.items.addAll(nodeMarkers)
nodeClusterer.invalidate()
@@ -246,8 +267,6 @@ fun MapView(
val haptic = LocalHapticFeedback.current
fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- val hasGps = remember { context.hasGps() }
-
// Accompanist permissions state for location
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
@@ -671,6 +690,51 @@ fun MapView(
}
}
+ fun MapView.onTracksChanged(nodeTracks: List?, focusedNodeNum: Int?): Pair, List> {
+ if (nodeTracks == null || focusedNodeNum == null) return emptyList() to emptyList()
+
+ val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
+ val timeFilteredPositions =
+ nodeTracks.filter {
+ lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
+ }
+ val sortedPositions = timeFilteredPositions.sortedBy { it.time }
+
+ val focusedNode = nodes.find { it.num == focusedNodeNum } ?: return emptyList() to emptyList()
+ val color = focusedNode.colors.second
+
+ val trackPolylines = mutableListOf()
+ if (sortedPositions.size > 1) {
+ val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
+ segments.forEachIndexed { index, segmentPoints ->
+ val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
+ val polyline =
+ Polyline().apply {
+ setPoints(
+ segmentPoints.map { GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) },
+ )
+ outlinePaint.color = Color(color).copy(alpha = alpha).toArgb()
+ outlinePaint.strokeWidth = 8f
+ }
+ trackPolylines.add(polyline)
+ }
+ }
+
+ val trackMarkers =
+ sortedPositions.mapIndexedNotNull { index, position ->
+ if (index == sortedPositions.lastIndex) return@mapIndexedNotNull null
+
+ Marker(this).apply {
+ this.position = GeoPoint((position.latitude_i ?: 0) * 1e-7, (position.longitude_i ?: 0) * 1e-7)
+ icon = AppCompatResources.getDrawable(context, R.drawable.ic_map_location_dot)
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
+ title = getString(Res.string.position)
+ snippet = formatAgo(position.time)
+ }
+ }
+ return trackMarkers to trackPolylines
+ }
+
Scaffold(
floatingActionButton = {
DownloadButton(showDownloadButton && downloadRegionBoundingBox == null) { showCacheManagerDialog = true }
@@ -687,10 +751,13 @@ fun MapView(
modifier = Modifier.fillMaxSize(),
update = { mapView ->
mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints)
+ val (trackMarkers, trackPolylines) = mapView.onTracksChanged(nodeTracks, focusedNodeNum)
with(mapView) {
updateMarkers(
onNodesChanged(nodesForMarkers),
onWaypointChanged(waypoints.values, selectedWaypointId),
+ trackMarkers,
+ trackPolylines,
nodeClusterer,
)
}
@@ -723,7 +790,7 @@ fun MapView(
MapButton(
onClick = { mapFilterExpanded = true },
icon = Icons.Outlined.Tune,
- contentDescription = Res.string.map_filter,
+ contentDescription = stringResource(Res.string.map_filter),
)
DropdownMenu(
expanded = mapFilterExpanded,
@@ -808,22 +875,20 @@ fun MapView(
)
}
}
- if (hasGps) {
- MapButton(
- icon =
- if (myLocationOverlay == null) {
- Icons.Outlined.MyLocation
- } else {
- Icons.Rounded.LocationDisabled
- },
- contentDescription = stringResource(Res.string.toggle_my_position),
- ) {
- if (locationPermissionsState.allPermissionsGranted) {
- map.toggleMyLocation()
- } else {
- triggerLocationToggleAfterPermission = true
- locationPermissionsState.launchMultiplePermissionRequest()
- }
+ MapButton(
+ icon =
+ if (myLocationOverlay == null) {
+ Icons.Outlined.MyLocation
+ } else {
+ Icons.Rounded.LocationDisabled
+ },
+ contentDescription = stringResource(Res.string.toggle_my_position),
+ ) {
+ if (locationPermissionsState.allPermissionsGranted) {
+ map.toggleMyLocation()
+ } else {
+ triggerLocationToggleAfterPermission = true
+ locationPermissionsState.launchMultiplePermissionRequest()
}
}
}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt
index 03081c3ab..82bfe9f85 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt
@@ -18,6 +18,7 @@
package org.meshtastic.feature.map
+import android.Manifest
import android.app.Activity
import android.content.Intent
import android.graphics.Canvas
@@ -62,6 +63,8 @@ import androidx.core.graphics.createBitmap
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
@@ -133,7 +136,12 @@ private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
@Suppress("CyclomaticComplexMethod", "LongMethod")
-@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+@OptIn(
+ MapsComposeExperimentalApi::class,
+ ExperimentalMaterial3Api::class,
+ ExperimentalMaterial3ExpressiveApi::class,
+ ExperimentalPermissionsApi::class,
+)
@Composable
fun MapView(
mapViewModel: MapViewModel = hiltViewModel(),
@@ -147,14 +155,24 @@ fun MapView(
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
- var hasLocationPermission by remember { mutableStateOf(false) }
val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
+ // Location permissions state
+ val locationPermissionsState =
+ rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
+ var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
+
// Location tracking state
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
var followPhoneBearing by remember { mutableStateOf(false) }
- LocationPermissionsHandler { isGranted -> hasLocationPermission = isGranted }
+ // Effect to toggle location tracking after permission is granted
+ LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
+ if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
+ isLocationTrackingEnabled = true
+ triggerLocationToggleAfterPermission = false
+ }
+ }
val filePickerLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
@@ -226,8 +244,8 @@ fun MapView(
}
// Start/stop location tracking based on state
- LaunchedEffect(isLocationTrackingEnabled, hasLocationPermission) {
- if (isLocationTrackingEnabled && hasLocationPermission) {
+ LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) {
+ if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) {
val locationRequest =
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
.setMinUpdateIntervalMillis(2000L)
@@ -431,7 +449,11 @@ fun MapView(
zoomGesturesEnabled = true,
),
properties =
- MapProperties(mapType = effectiveGoogleMapType, isMyLocationEnabled = hasLocationPermission),
+ MapProperties(
+ mapType = effectiveGoogleMapType,
+ isMyLocationEnabled =
+ isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted,
+ ),
onMapLongClick = { latLng ->
if (isConnected) {
val newWaypoint =
@@ -457,7 +479,7 @@ fun MapView(
jointType = JointType.ROUND,
color = TracerouteColors.OutgoingRoute,
width = 9f,
- zIndex = 1.5f,
+ zIndex = 3.0f,
)
}
if (tracerouteReturnPoints.size >= 2) {
@@ -466,7 +488,7 @@ fun MapView(
jointType = JointType.ROUND,
color = TracerouteColors.ReturnRoute,
width = 7f,
- zIndex = 1.4f,
+ zIndex = 2.5f,
)
}
@@ -482,26 +504,33 @@ fun MapView(
.find { it.num == focusedNodeNum }
?.let { focusedNode ->
sortedPositions.forEachIndexed { index, position ->
- val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
- val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
- val color = Color(focusedNode.colors.second).copy(alpha = alpha)
- if (index == sortedPositions.lastIndex) {
- MarkerComposable(state = markerState, zIndex = 1f) { NodeChip(node = focusedNode) }
- } else {
- MarkerInfoWindowComposable(
- state = markerState,
- title = stringResource(Res.string.position),
- snippet = formatAgo(position.time),
- zIndex = alpha,
- infoContent = {
- PositionInfoWindowContent(position = position, displayUnits = displayUnits)
- },
- ) {
- Icon(
- imageVector = androidx.compose.material.icons.Icons.Rounded.TripOrigin,
- contentDescription = stringResource(Res.string.track_point),
- tint = color,
- )
+ key(position.time) {
+ val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
+ val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
+ val color = Color(focusedNode.colors.second).copy(alpha = alpha)
+ if (index == sortedPositions.lastIndex) {
+ MarkerComposable(state = markerState, zIndex = 4f) {
+ NodeChip(node = focusedNode)
+ }
+ } else {
+ MarkerInfoWindowComposable(
+ state = markerState,
+ title = stringResource(Res.string.position),
+ snippet = formatAgo(position.time),
+ zIndex = 1f + alpha,
+ infoContent = {
+ PositionInfoWindowContent(
+ position = position,
+ displayUnits = displayUnits,
+ )
+ },
+ ) {
+ Icon(
+ imageVector = androidx.compose.material.icons.Icons.Rounded.TripOrigin,
+ contentDescription = stringResource(Res.string.track_point),
+ tint = color,
+ )
+ }
}
}
}
@@ -515,6 +544,7 @@ fun MapView(
jointType = JointType.ROUND,
color = Color(focusedNode.colors.second).copy(alpha = alpha),
width = 8f,
+ zIndex = 0.6f,
)
}
}
@@ -550,25 +580,6 @@ fun MapView(
)
}
- if (tracerouteForwardPoints.size >= 2) {
- Polyline(
- points = tracerouteForwardOffsetPoints,
- jointType = JointType.ROUND,
- color = TracerouteColors.OutgoingRoute,
- width = 9f,
- zIndex = 2f,
- )
- }
- if (tracerouteReturnPoints.size >= 2) {
- Polyline(
- points = tracerouteReturnOffsetPoints,
- jointType = JointType.ROUND,
- color = TracerouteColors.ReturnRoute,
- width = 7f,
- zIndex = 1.5f,
- )
- }
-
WaypointMarkers(
displayableWaypoints = displayableWaypoints,
mapFilterState = mapFilterState,
@@ -655,14 +666,16 @@ fun MapView(
showCustomTileManagerSheet = true
},
isNodeMap = focusedNodeNum != null,
- hasLocationPermission = hasLocationPermission,
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = {
- if (hasLocationPermission) {
+ if (locationPermissionsState.allPermissionsGranted) {
isLocationTrackingEnabled = !isLocationTrackingEnabled
if (!isLocationTrackingEnabled) {
followPhoneBearing = false
}
+ } else {
+ triggerLocationToggleAfterPermission = true
+ locationPermissionsState.launchMultiplePermissionRequest()
}
},
bearing = cameraPositionState.position.bearing,
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
index c2bccf97d..7ad618683 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
@@ -56,7 +56,6 @@ fun MapControlsOverlay(
onManageCustomTileProvidersClicked: () -> Unit, // New parameter
isNodeMap: Boolean,
// Location tracking parameters
- hasLocationPermission: Boolean = false,
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
bearing: Float = 0f,
@@ -117,18 +116,16 @@ fun MapControlsOverlay(
)
// Location tracking button
- if (hasLocationPermission) {
- MapButton(
- icon =
- if (isLocationTrackingEnabled) {
- Icons.Rounded.LocationDisabled
- } else {
- Icons.Outlined.MyLocation
- },
- contentDescription = stringResource(Res.string.toggle_my_position),
- onClick = onToggleLocationTracking,
- )
- }
+ MapButton(
+ icon =
+ if (isLocationTrackingEnabled) {
+ Icons.Rounded.LocationDisabled
+ } else {
+ Icons.Outlined.MyLocation
+ },
+ contentDescription = stringResource(Res.string.toggle_my_position),
+ onClick = onToggleLocationTracking,
+ )
},
)
}
From 35854220971f2131b2c13aac4ea81b1f7023e51f Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 26 Feb 2026 15:12:58 -0600
Subject: [PATCH 023/474] chore(deps): update github artifact actions (major)
(#4659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/main-push-changelog.yml | 2 +-
.github/workflows/release.yml | 8 ++++----
.github/workflows/reusable-check.yml | 4 ++--
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/main-push-changelog.yml b/.github/workflows/main-push-changelog.yml
index 4f0fd9a0e..ff1513535 100644
--- a/.github/workflows/main-push-changelog.yml
+++ b/.github/workflows/main-push-changelog.yml
@@ -40,7 +40,7 @@ jobs:
- name: Upload changelog artifact
if: steps.last_prod_tag.outputs.tag != ''
- uses: actions/upload-artifact@v6
+ uses: actions/upload-artifact@v7
with:
name: main-push-changelog
path: main-push-changelog.md
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 15cd3555b..2db494892 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -181,7 +181,7 @@ jobs:
- name: Upload Google AAB artifact
if: always()
- uses: actions/upload-artifact@v6
+ uses: actions/upload-artifact@v7
with:
name: google-aab
path: app/build/outputs/bundle/googleRelease/app-google-release.aab
@@ -189,7 +189,7 @@ jobs:
- name: Upload Google APK artifact
if: always()
- uses: actions/upload-artifact@v6
+ uses: actions/upload-artifact@v7
with:
name: google-apk
path: app/build/outputs/apk/**/*.apk
@@ -257,7 +257,7 @@ jobs:
run: ls -R app/build/outputs/
- name: Upload F-Droid APK artifact
- uses: actions/upload-artifact@v6
+ uses: actions/upload-artifact@v7
with:
name: fdroid-apk
path: app/build/outputs/apk/**/*.apk
@@ -279,7 +279,7 @@ jobs:
fetch-depth: 0
- name: Download all artifacts
- uses: actions/download-artifact@v7
+ uses: actions/download-artifact@v8
with:
path: ./artifacts
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 22da1a3b4..85b9d46ba 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -179,7 +179,7 @@ jobs:
- name: Upload debug artifact
if: ${{ steps.tasks.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
- uses: actions/upload-artifact@v6
+ uses: actions/upload-artifact@v7
with:
name: ${{ matrix.flavor }}Debug
path: app/build/outputs/apk/${{ matrix.flavor }}/debug/app-${{ matrix.flavor }}-debug.apk
@@ -195,7 +195,7 @@ jobs:
- name: Upload reports
if: ${{ always() && inputs.upload_artifacts }}
- uses: actions/upload-artifact@v6
+ uses: actions/upload-artifact@v7
with:
name: reports-${{ matrix.flavor }}-api-${{ matrix.api_level }}
path: |
From ddc05ddfb78bce11a79d49d75b5cfaf13a09ed4b Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Thu, 26 Feb 2026 15:51:51 -0600
Subject: [PATCH 024/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4660)
---
app/src/main/assets/firmware_releases.json | 6 ------
1 file changed, 6 deletions(-)
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 3ef09fbba..dcb81f56d 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -205,12 +205,6 @@
"title": "add FromRadioSync BLE characteristic",
"page_url": "https://github.com/meshtastic/firmware/pull/9675",
"zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9627",
- "title": "Add phone API fanout for multiple clients",
- "page_url": "https://github.com/meshtastic/firmware/pull/9627",
- "zip_url": "https://discord.com/invite/meshtastic"
}
]
}
\ No newline at end of file
From ce9a3b5403e5fbf8cd5d7604500074971e9eb8ec Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 26 Feb 2026 17:04:53 -0600
Subject: [PATCH 025/474] chore(deps): update com.google.firebase:firebase-bom
to v34.10.0 (#4661)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 737094bca..124528478 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -133,7 +133,7 @@ compose-multiplatform-resources = { module = "org.jetbrains.compose.components:c
# Google
firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
-firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.9.0" }
+firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.10.0" }
firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }
guava = { module = "com.google.guava:guava", version = "33.5.0-jre" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
From 728ef0c62bab429ec3f4b1539ceb98787419fda3 Mon Sep 17 00:00:00 2001
From: Alec Perkins <99231+alecperkins@users.noreply.github.com>
Date: Thu, 26 Feb 2026 18:31:37 -0500
Subject: [PATCH 026/474] fix(strings): Correct capitalization of Ham (#4620)
---
.../src/commonMain/composeResources/values/strings.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index ad08c00a2..8a46b2000 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -741,7 +741,7 @@
Long Name
Short Name
Hardware model
- Licensed amateur radio (HAM)
+ Licensed amateur radio (Ham)
Enabling this option disables encryption and is not compatible with the default Meshtastic network.
Dew Point
Pressure
From 22c239016b59348d949dab0e01093f661b975ad9 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 27 Feb 2026 00:32:32 +0000
Subject: [PATCH 027/474] chore(release): prepare v2.7.14-internal.2 [skip ci]
- Bump base version to 2.7.14
- Sync translations and assets
---
.../src/commonMain/composeResources/values-bg/strings.xml | 1 -
.../src/commonMain/composeResources/values-cs/strings.xml | 1 -
.../src/commonMain/composeResources/values-de/strings.xml | 1 -
.../src/commonMain/composeResources/values-es/strings.xml | 1 -
.../src/commonMain/composeResources/values-et/strings.xml | 1 -
.../src/commonMain/composeResources/values-fi/strings.xml | 1 -
.../src/commonMain/composeResources/values-fr/strings.xml | 1 -
.../src/commonMain/composeResources/values-hu/strings.xml | 1 -
.../src/commonMain/composeResources/values-it/strings.xml | 1 -
.../src/commonMain/composeResources/values-ja/strings.xml | 1 -
.../src/commonMain/composeResources/values-ko/strings.xml | 1 -
.../src/commonMain/composeResources/values-pt-rBR/strings.xml | 1 -
.../src/commonMain/composeResources/values-pt/strings.xml | 1 -
.../src/commonMain/composeResources/values-ro/strings.xml | 1 -
.../src/commonMain/composeResources/values-ru/strings.xml | 1 -
.../src/commonMain/composeResources/values-sk/strings.xml | 1 -
.../src/commonMain/composeResources/values-sv/strings.xml | 1 -
.../src/commonMain/composeResources/values-tr/strings.xml | 1 -
.../src/commonMain/composeResources/values-uk/strings.xml | 1 -
.../src/commonMain/composeResources/values-zh-rCN/strings.xml | 1 -
.../src/commonMain/composeResources/values-zh-rTW/strings.xml | 1 -
21 files changed, 21 deletions(-)
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index 43fb67f2b..625f903e4 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -545,7 +545,6 @@
Дълго име
Кратко име
Модел на хардуера
- Лицензиран радиолюбител (HAM)
Активирането на тази опция дезактивира криптирането и не е съвместимо с мрежата Meshtastic по подразбиране.
Точка на оросяване
Налягане
diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
index dca38e6c2..43df26ca7 100644
--- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
@@ -618,7 +618,6 @@
Dlouhé jméno
Krátké jméno
Hardwarový model
- Licencované amatérské rádio (HAM)
Povolení této možnosti zruší šifrování a není kompatibilní se základním nastavením Meshtastic sítě.
Rosný bod
Tlak
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index baed4ffc7..f5311fe9a 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -714,7 +714,6 @@
Langer Name
Kurzname
Geräte-Modell
- Amateurfunk lizenziert
Das Aktivieren dieser Option deaktiviert die Verschlüsselung und ist nicht mit dem Standardnetzwerk von Meshtastic kompatibel.
Taupunkt
Druck
diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml
index efe7b11a5..cfded8cda 100644
--- a/core/resources/src/commonMain/composeResources/values-es/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml
@@ -643,7 +643,6 @@ Rango de Valores 0 - 500.
Nombre largo
Nombre Corto
Modelo del dispositivo
- Licencia de radioaficionado (no necesaria)
Activando esta opción se desactiva la encriptación y deja de ser compatible con la red de Meshtastic normal.
Punto de rocío
Presión
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index 6a7468e0d..e705b952a 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -714,7 +714,6 @@
Täis nimi
Lühi nimi
Seadme mudel
- Litsentseeritud amatöörraadio (HAM)
Selle valiku lubamine keelab krüpteerimise ja ei ühildu Meshtastic vaikevõrguga.
Kastepunkt
Õhurõhk
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 4d9d6143d..846967343 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -714,7 +714,6 @@
Pitkä nimi
Lyhytnimi
Laitteen malli
- Lisensoitu radioamatööri (HAM)
Jos otat tämän asetuksen käyttöön, salaus poistetaan käytöstä, eikä laite ole enää yhteensopiva oletusasetuksilla toimivan Meshtastic-verkon kanssa.
Kastepiste
Ilmanpaine
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index a888e723a..68e89ee88 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -694,7 +694,6 @@
Nom long
Nom court
Modèle de matériel
- Radioamateur licencié (HAM)
L'activation de cette option désactive le chiffrement et n'est pas compatible avec le réseau Meshtastic par défaut.
Point de rosée
Pression
diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
index 5aa6d4834..ac09eeb84 100644
--- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
@@ -671,7 +671,6 @@
Hosszú név
Rövid név
Hardvermodell
- Engedélyezett amatőrrádió (HAM)
Ennek az opciónak az engedélyezése letiltja a titkosítást, és nem kompatibilis az alapértelmezett Meshtastic hálózattal.
Harmatpont
Nyomás
diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml
index 869922b31..34bd80c84 100644
--- a/core/resources/src/commonMain/composeResources/values-it/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml
@@ -637,7 +637,6 @@
Nome Lungo
Nome Breve
Modello hardware
- Radioamatori con licenza (HAM)
Abilitare questa opzione disabilita la crittografia e non è compatibile con la rete Meshtastic predefinita.
Punto Di Rugiada
Pressione
diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
index 565b82def..0e97438b5 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -460,7 +460,6 @@
ユーザー設定
ノード ID
ハードウェアのモデル
- アマチュア無線免許所持者向け (HAM)
このオプションを有効にすると、暗号化が無効になりデフォルトのMeshtasticネットワークと互換性が無くなります。
露点
気圧
diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
index 99173ea7a..fc578018e 100644
--- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
@@ -468,7 +468,6 @@
긴 이름
짧은 이름
하드웨어 모델
- 아마추어무선 자격 보유 (HAM)
이 옵션을 활성화하면 암호화가 비활성화되며 기본 Meshtastic 네트워크와 호환되지 않습니다.
이슬점
기압
diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
index 0f07afe5b..fe90da9a6 100644
--- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
@@ -489,7 +489,6 @@
Configuração do Usuário
ID do Nó
Modelo de hardware
- Rádio Amador Licenciado (HAM)
Ativar esta opção desativa a criptografia e não é compatível com a rede padrão do Meshtastic.
Ponto de orvalho
Pressão
diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
index 70b35e272..1144ac7d1 100644
--- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
@@ -477,7 +477,6 @@
Configuração do Utilizador
ID do Node
Modelo de hardware
- Rádio amador licenciado (HAM)
Ativar esta opção desativa a encriptação e não é compatível com a rede Meshtastic normal.
Ponto de Condensação
Pressão
diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
index 267543232..01657ac4d 100644
--- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
@@ -601,7 +601,6 @@
Nume lung
Nume scurt
Model hardware
- Radioamator autorizat
Punct de rouă
Presiune
Distanță
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index b052fa58a..b834de90c 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -722,7 +722,6 @@
Полное имя
Короткое имя
Модель оборудования
- Лицензированный радиолюбитель (HAM)
Включение данной опции отключает шифрование и несовместимо с основной сетью Meshtastic.
Точка росы
Давление
diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
index a85f2e836..299bb1b21 100644
--- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
@@ -415,7 +415,6 @@
Dlhé Meno
Krátke Meno
Model hardvéru
- Licencovaný rádio amatér (HAM)
Tlak
Vzdialenosť
Lux
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index d79b8af5e..92d8b3e87 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -675,7 +675,6 @@
Långt namn
Kort namn
Hårdvarumodell
- Licensierad radioamatör (HAM)
Aktivering detta alternativ inaktiverar kryptering och är inte kompatibelt med standard Meshtastic-nätverk.
Daggpunkt
Tryck
diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
index e5ec12c86..f7bf2c4ac 100644
--- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
@@ -472,7 +472,6 @@
Uzun Ad
Kısa Ad
Donanım modeli
- Lisanslı amatör radyo
Bu seçeneği aktif etmek şifrelemeyi devre dışı bırakır ve bu varsayılan Meshtastic ağı ile uyumsuzdur.
Çiğ Noktası
Basınç
diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
index 97c86ca2f..6b38ad9d5 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -516,7 +516,6 @@
Довга назва
Коротка назва
Модель обладнання
- Ліцензований радіоаматор
Включення цієї опції вимикає шифрування і не сумісне зі стандартною мережею Meshtastic.
Атмосферний тиск
Відстань
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index d89f6a908..60f86367c 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -712,7 +712,6 @@
长名称
短名称
硬件型号
- 业余无线电模式(HAM)
启用此选项将禁用加密并且不兼容默认的Meshtastic网络。
结露点
气压
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index 9683f22dd..9287780de 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -710,7 +710,6 @@
裝置長名稱
裝置短名稱
硬體型號
- 業餘無線電模式 (HAM)
啟用此選項將停用訊息加密,並與預設的 Meshtastic 網路不相容。
露點
氣壓
From 0a6fcc830a9f71c51a0ad31235c8897b5a8baaee Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 08:36:56 -0600
Subject: [PATCH 028/474] feat(maps): Google maps improvements for network and
offline tilesources (#4664)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../com/geeksville/mesh/MapsInitializer.kt | 25 --
.../com/geeksville/mesh/MapsInitializer.kt | 25 --
app/src/main/AndroidManifest.xml | 4 +-
.../geeksville/mesh/MeshUtilApplication.kt | 1 -
.../data/model/CustomTileProviderConfig.kt | 6 +-
.../core/prefs/map/GoogleMapsPrefs.kt | 5 +-
.../composeResources/values/strings.xml | 25 +-
feature/intro/build.gradle.kts | 16 +-
.../feature/intro/AppIntroductionScreen.kt | 170 +++-------
.../meshtastic/feature/intro/IntroNavGraph.kt | 129 ++++++++
.../feature/intro/IntroViewModel.kt | 40 +++
.../feature/intro/IntroViewModelTest.kt | 73 +++++
feature/map/build.gradle.kts | 6 +
.../meshtastic/feature/map/MBTilesProvider.kt | 65 ++++
.../org/meshtastic/feature/map/MapView.kt | 118 ++++---
.../meshtastic/feature/map/MapViewModel.kt | 309 ++++++++++++------
.../map/component/CustomMapLayersSheet.kt | 91 +++++-
.../CustomTileProviderManagerSheet.kt | 78 ++++-
.../map/component/MapControlsOverlay.kt | 23 ++
.../map/component/NodeClusterMarkers.kt | 3 +-
.../feature/map/model/NodeClusterItem.kt | 15 +-
.../feature/map/MBTilesProviderTest.kt | 63 ++++
.../feature/map/MapViewModelTest.kt | 149 +++++++++
23 files changed, 1086 insertions(+), 353 deletions(-)
delete mode 100644 app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt
delete mode 100644 app/src/google/java/com/geeksville/mesh/MapsInitializer.kt
create mode 100644 feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt
create mode 100644 feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt
create mode 100644 feature/intro/src/test/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt
create mode 100644 feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt
create mode 100644 feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt
create mode 100644 feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
diff --git a/app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt b/app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt
deleted file mode 100644
index 8ae95519c..000000000
--- a/app/src/fdroid/java/com/geeksville/mesh/MapsInitializer.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.geeksville.mesh
-
-import android.content.Context
-
-@Suppress("UNUSED_PARAMETER")
-fun initializeMaps(context: Context) {
- // No-op for F-Droid
-}
diff --git a/app/src/google/java/com/geeksville/mesh/MapsInitializer.kt b/app/src/google/java/com/geeksville/mesh/MapsInitializer.kt
deleted file mode 100644
index 5ae9b3963..000000000
--- a/app/src/google/java/com/geeksville/mesh/MapsInitializer.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (c) 2025 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package com.geeksville.mesh
-
-import android.content.Context
-import com.google.android.gms.maps.MapsInitializer
-
-fun initializeMaps(context: Context) {
- MapsInitializer.initialize(context)
-}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 90a786cb1..3c0e623aa 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -44,8 +44,8 @@
-
-
+
+
diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
index 8bb2e3dbb..9843c49f9 100644
--- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
+++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt
@@ -69,7 +69,6 @@ open class MeshUtilApplication :
override fun onCreate() {
super.onCreate()
ContextServices.app = this
- initializeMaps(this)
// Schedule periodic MeshLog cleanup
scheduleMeshLogCleanup()
diff --git a/core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt b/core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt
index d35035985..434aa834e 100644
--- a/core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt
+++ b/core/data/src/google/kotlin/org/meshtastic/core/data/model/CustomTileProviderConfig.kt
@@ -24,4 +24,8 @@ data class CustomTileProviderConfig(
val id: String = Uuid.random().toString(),
val name: String,
val urlTemplate: String,
-)
+ val localUri: String? = null,
+) {
+ val isLocal: Boolean
+ get() = localUri != null
+}
diff --git a/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt b/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt
index c749eba1c..73942c308 100644
--- a/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.kt
+++ b/core/prefs/src/google/kotlin/org/meshtastic/core/prefs/map/GoogleMapsPrefs.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.prefs.map
import android.content.SharedPreferences
@@ -37,6 +36,7 @@ interface GoogleMapsPrefs {
var cameraZoom: Float
var cameraTilt: Float
var cameraBearing: Float
+ var networkMapLayers: Set
}
@Singleton
@@ -50,4 +50,5 @@ class GoogleMapsPrefsImpl @Inject constructor(@GoogleMapsSharedPreferences prefs
override var cameraZoom: Float by FloatPrefDelegate(prefs, "camera_zoom", 7f)
override var cameraTilt: Float by FloatPrefDelegate(prefs, "camera_tilt", 0f)
override var cameraBearing: Float by FloatPrefDelegate(prefs, "camera_bearing", 0f)
+ override var networkMapLayers: Set by StringSetPrefDelegate(prefs, "network_map_layers", emptySet())
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 8a46b2000..7376bd0a0 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -970,9 +970,9 @@
Terrain
Hybrid
Manage Map Layers
- Custom layers support .kml, .kmz, or GeoJSON files.
+ Map layers support .kml, .kmz, or GeoJSON formats.
Map Layers
- No custom layers loaded.
+ No map layers loaded.
Add Layer
Hide Layer
Show Layer
@@ -981,16 +981,16 @@
Nodes at this location
Selected Map Type
Manage Custom Tile Sources
- Add Custom Tile Source
- No Custom Tile Sources
- Edit Custom Tile Source
- Delete Custom Tile Source
+ Add Network Tile Source
+ No custom tile sources found.
+ Edit Network Tile Source
+ Delete Network Tile Source
Name cannot be empty.
Provider name exists.
URL cannot be empty.
URL must contain placeholders.
URL Template
- https://a.tile.openstreetmap.org/{z}/{x}/{y}.png
+ https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
track point
App
Version
@@ -1212,4 +1212,15 @@
Meshtastic Stats
Refresh
Updated
+
+
+ Add Network Layer
+ https://example.com/map.kml or .geojson
+ Refresh Layer
+
+ Local MBTiles File
+ Add Local MBTiles File
+ Invalid name, URL template, or local URI for custom tile provider.
+ A custom tile provider with this name already exists.
+ Failed to copy MBTiles file to internal storage.
diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts
index 21aa1b2db..026918527 100644
--- a/feature/intro/build.gradle.kts
+++ b/feature/intro/build.gradle.kts
@@ -24,17 +24,27 @@ plugins {
alias(libs.plugins.meshtastic.kotlinx.serialization)
}
-configure { namespace = "org.meshtastic.feature.intro" }
+configure {
+ namespace = "org.meshtastic.feature.intro"
+ testOptions { unitTests { isIncludeAndroidResources = true } }
+}
dependencies {
implementation(projects.core.resources)
implementation(projects.core.ui)
+ implementation(libs.accompanist.permissions)
implementation(libs.androidx.compose.material.iconsExtended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
- implementation(libs.nordic.common.permissions.ble)
- implementation(libs.nordic.common.permissions.notification)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.mockk)
+ testImplementation(libs.robolectric)
+ testImplementation(platform(libs.androidx.compose.bom))
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
}
diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
index 275dd84b4..5147bef41 100644
--- a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
+++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
@@ -16,150 +16,60 @@
*/
package org.meshtastic.feature.intro
-import android.content.Intent
-import android.provider.Settings
+import android.Manifest
+import android.os.Build
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.platform.LocalContext
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation3.runtime.NavKey
-import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
-import kotlinx.serialization.Serializable
-import no.nordicsemi.android.common.permissions.ble.RequireBluetooth
-import no.nordicsemi.android.common.permissions.ble.RequireLocation
-import no.nordicsemi.android.common.permissions.notification.RequestNotificationPermission
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.permission_denied
-import org.meshtastic.core.resources.permission_granted
-import org.meshtastic.core.ui.util.showToast
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.PermissionState
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import com.google.accompanist.permissions.rememberPermissionState
/**
- * Composable function for the main application introduction screen. This screen guides the user through initial setup
- * steps like granting permissions.
+ * Main application introduction screen. This Composable hosts the navigation flow and hoists the permission states.
*
* @param onDone Callback invoked when the introduction flow is completed.
+ * @param viewModel ViewModel for tracking the introduction flow state.
*/
-@Suppress("LongMethod", "CyclomaticComplexMethod")
+@OptIn(ExperimentalPermissionsApi::class)
@Composable
-fun AppIntroductionScreen(onDone: () -> Unit) {
- val context = LocalContext.current
+fun AppIntroductionScreen(onDone: () -> Unit, @Suppress("unused") viewModel: IntroViewModel = hiltViewModel()) {
+ val notificationPermissionState: PermissionState? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ null
+ }
+
+ val locationPermissions =
+ listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
+ val locationPermissionState = rememberMultiplePermissionsState(permissions = locationPermissions)
+
+ val bluetoothPermissions =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
+ } else {
+ // On older versions, location permission is used for scanning.
+ emptyList()
+ }
+ val bluetoothPermissionState = rememberMultiplePermissionsState(permissions = bluetoothPermissions)
+
val backStack = rememberNavBackStack(Welcome)
- NavDisplay(
+ NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider =
- entryProvider {
- entry { WelcomeScreen(onGetStarted = { backStack.add(Notifications) }) }
-
- entry {
- var isConfiguring by remember { mutableStateOf(false) }
-
- if (isConfiguring) {
- RequestNotificationPermission { canShowNotifications ->
- LaunchedEffect(canShowNotifications) {
- if (canShowNotifications == true) {
- context.showToast(Res.string.permission_granted)
- } else if (canShowNotifications == false) {
- context.showToast(Res.string.permission_denied)
- }
- }
-
- NotificationsScreen(
- showNextButton = canShowNotifications == true,
- onSkip = { backStack.add(Bluetooth) },
- onConfigure = {
- if (canShowNotifications == true) {
- backStack.add(CriticalAlerts)
- }
- },
- )
- }
- } else {
- NotificationsScreen(
- showNextButton = false,
- onSkip = { backStack.add(Bluetooth) },
- onConfigure = { isConfiguring = true },
- )
- }
- }
-
- entry {
- CriticalAlertsScreen(
- onSkip = { backStack.add(Bluetooth) },
- onConfigure = {
- val intent =
- Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
- putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
- putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
- }
- context.startActivity(intent)
- backStack.add(Bluetooth)
- },
- )
- }
-
- entry {
- var isConfiguring by remember { mutableStateOf(false) }
-
- if (isConfiguring) {
- RequireBluetooth {
- LaunchedEffect(Unit) {
- context.showToast(Res.string.permission_granted)
- backStack.add(Location)
- }
- }
- } else {
- BluetoothScreen(
- showNextButton = false,
- onSkip = { backStack.add(Location) },
- onConfigure = { isConfiguring = true },
- )
- }
- }
-
- entry {
- var isConfiguring by remember { mutableStateOf(false) }
-
- if (isConfiguring) {
- RequireLocation { isLocationRequiredAndDisabled ->
- LaunchedEffect(isLocationRequiredAndDisabled) {
- if (!isLocationRequiredAndDisabled) {
- context.showToast(Res.string.permission_granted)
- } else {
- context.showToast(Res.string.permission_denied)
- }
- }
-
- LocationScreen(
- showNextButton = !isLocationRequiredAndDisabled,
- onSkip = onDone,
- onConfigure = {
- if (!isLocationRequiredAndDisabled) {
- onDone()
- }
- },
- )
- }
- } else {
- LocationScreen(showNextButton = false, onSkip = onDone, onConfigure = { isConfiguring = true })
- }
- }
- },
+ introNavGraph(
+ backStack = backStack,
+ viewModel = viewModel,
+ notificationPermissionState = notificationPermissionState,
+ bluetoothPermissionState = bluetoothPermissionState,
+ locationPermissionState = locationPermissionState,
+ onDone = onDone,
+ ),
)
}
-
-@Serializable private data object Welcome : NavKey
-
-@Serializable private data object Notifications : NavKey
-
-@Serializable private data object CriticalAlerts : NavKey
-
-@Serializable private data object Bluetooth : NavKey
-
-@Serializable private data object Location : NavKey
diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt
new file mode 100644
index 000000000..05c82bdd0
--- /dev/null
+++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt
@@ -0,0 +1,129 @@
+/*
+ * 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.feature.intro
+
+import android.content.Intent
+import android.provider.Settings
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.MultiplePermissionsState
+import com.google.accompanist.permissions.PermissionState
+import com.google.accompanist.permissions.isGranted
+import kotlinx.serialization.Serializable
+
+@Serializable data object Welcome : NavKey
+
+@Serializable data object Bluetooth : NavKey
+
+@Serializable data object Location : NavKey
+
+@Serializable data object Notifications : NavKey
+
+@Serializable data object CriticalAlerts : NavKey
+
+/**
+ * Provides the navigation graph for the application introduction flow. The flow follows the hierarchy of necessity:
+ * Core Connection -> Shared Location -> Notifications.
+ */
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+@Suppress("LongMethod")
+internal fun introNavGraph(
+ backStack: NavBackStack,
+ viewModel: IntroViewModel,
+ notificationPermissionState: PermissionState?,
+ bluetoothPermissionState: MultiplePermissionsState,
+ locationPermissionState: MultiplePermissionsState,
+ onDone: () -> Unit,
+) = entryProvider {
+ val context = LocalContext.current
+
+ fun navigateToNext(current: NavKey, permissionsGranted: Boolean = true) {
+ val next = viewModel.getNextKey(current, permissionsGranted)
+ if (next != null) {
+ backStack.add(next)
+ } else {
+ onDone()
+ }
+ }
+
+ entry { WelcomeScreen(onGetStarted = { navigateToNext(Welcome) }) }
+
+ entry {
+ val isGranted = bluetoothPermissionState.allPermissionsGranted
+ BluetoothScreen(
+ showNextButton = isGranted,
+ onSkip = { navigateToNext(Bluetooth) },
+ onConfigure = {
+ if (isGranted) {
+ navigateToNext(Bluetooth)
+ } else {
+ bluetoothPermissionState.launchMultiplePermissionRequest()
+ }
+ },
+ )
+ }
+
+ entry {
+ val isGranted = locationPermissionState.allPermissionsGranted
+ LocationScreen(
+ showNextButton = isGranted,
+ onSkip = { navigateToNext(Location) },
+ onConfigure = {
+ if (isGranted) {
+ navigateToNext(Location)
+ } else {
+ locationPermissionState.launchMultiplePermissionRequest()
+ }
+ },
+ )
+ }
+
+ entry {
+ val isGranted = notificationPermissionState?.status?.isGranted ?: true
+ NotificationsScreen(
+ showNextButton = isGranted,
+ onSkip = onDone,
+ onConfigure = {
+ if (notificationPermissionState != null && !isGranted) {
+ notificationPermissionState.launchPermissionRequest()
+ } else {
+ navigateToNext(Notifications, permissionsGranted = isGranted)
+ }
+ },
+ )
+ }
+
+ entry {
+ CriticalAlertsScreen(
+ onSkip = onDone,
+ onConfigure = {
+ val intent =
+ Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
+ }
+ context.startActivity(intent)
+ onDone()
+ },
+ )
+ }
+}
diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt
new file mode 100644
index 000000000..e76c007ed
--- /dev/null
+++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/IntroViewModel.kt
@@ -0,0 +1,40 @@
+/*
+ * 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.feature.intro
+
+import androidx.lifecycle.ViewModel
+import androidx.navigation3.runtime.NavKey
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+/** ViewModel for the app introduction flow. */
+@HiltViewModel
+class IntroViewModel @Inject constructor() : ViewModel() {
+
+ /**
+ * Determines the next navigation key based on the current key and the state of permissions. The flow hierarchy is:
+ * Core Connection -> Shared Location -> Notifications -> Done.
+ */
+ fun getNextKey(currentKey: NavKey, allPermissionsGranted: Boolean): NavKey? = when (currentKey) {
+ is Welcome -> Bluetooth
+ is Bluetooth -> Location
+ is Location -> Notifications
+ is Notifications -> if (allPermissionsGranted) CriticalAlerts else null
+ is CriticalAlerts -> null
+ else -> null
+ }
+}
diff --git a/feature/intro/src/test/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt b/feature/intro/src/test/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt
new file mode 100644
index 000000000..dfb129543
--- /dev/null
+++ b/feature/intro/src/test/kotlin/org/meshtastic/feature/intro/IntroViewModelTest.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.feature.intro
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class IntroViewModelTest {
+
+ @Test
+ fun `viewModel can be initialized`() {
+ val viewModel = IntroViewModel()
+ assertNotNull(viewModel)
+ }
+
+ @Test
+ fun `getNextKey returns Bluetooth after Welcome`() {
+ val viewModel = IntroViewModel()
+ val next = viewModel.getNextKey(Welcome, false)
+ assertEquals(Bluetooth, next)
+ }
+
+ @Test
+ fun `getNextKey returns Location after Bluetooth`() {
+ val viewModel = IntroViewModel()
+ val next = viewModel.getNextKey(Bluetooth, false)
+ assertEquals(Location, next)
+ }
+
+ @Test
+ fun `getNextKey returns Notifications after Location`() {
+ val viewModel = IntroViewModel()
+ val next = viewModel.getNextKey(Location, false)
+ assertEquals(Notifications, next)
+ }
+
+ @Test
+ fun `getNextKey returns CriticalAlerts after Notifications if granted`() {
+ val viewModel = IntroViewModel()
+ val next = viewModel.getNextKey(Notifications, true)
+ assertEquals(CriticalAlerts, next)
+ }
+
+ @Test
+ fun `getNextKey returns null after Notifications if not granted`() {
+ val viewModel = IntroViewModel()
+ val next = viewModel.getNextKey(Notifications, false)
+ assertNull(next)
+ }
+
+ @Test
+ fun `getNextKey returns null after CriticalAlerts`() {
+ val viewModel = IntroViewModel()
+ val next = viewModel.getNextKey(CriticalAlerts, false)
+ assertNull(next)
+ }
+}
diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts
index 539512077..c061bd993 100644
--- a/feature/map/build.gradle.kts
+++ b/feature/map/build.gradle.kts
@@ -62,4 +62,10 @@ dependencies {
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.mockk)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.androidx.test.core)
}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt
new file mode 100644
index 000000000..848779ccf
--- /dev/null
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MBTilesProvider.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.feature.map
+
+import android.database.sqlite.SQLiteDatabase
+import com.google.android.gms.maps.model.Tile
+import com.google.android.gms.maps.model.TileProvider
+import java.io.File
+
+class MBTilesProvider(private val file: File) :
+ TileProvider,
+ AutoCloseable {
+ private var database: SQLiteDatabase? = null
+
+ init {
+ openDatabase()
+ }
+
+ private fun openDatabase() {
+ if (database == null && file.exists()) {
+ database = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
+ }
+ }
+
+ override fun getTile(x: Int, y: Int, zoom: Int): Tile? {
+ val db = database ?: return null
+
+ var tile: Tile? = null
+ // Convert Google Maps y coordinate to standard TMS y coordinate
+ val tmsY = (1 shl zoom) - 1 - y
+
+ val cursor =
+ db.rawQuery(
+ "SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?",
+ arrayOf(zoom.toString(), x.toString(), tmsY.toString()),
+ )
+
+ if (cursor.moveToFirst()) {
+ val tileData = cursor.getBlob(0)
+ tile = Tile(256, 256, tileData)
+ }
+ cursor.close()
+
+ return tile ?: TileProvider.NO_TILE
+ }
+
+ override fun close() {
+ database?.close()
+ database = null
+ }
+}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt
index 82bfe9f85..99725a8f8 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt
@@ -91,8 +91,11 @@ import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
import com.google.maps.android.compose.rememberUpdatedMarkerState
import com.google.maps.android.compose.widgets.ScaleBar
+import com.google.maps.android.data.geojson.GeoJsonLayer
+import com.google.maps.android.data.kml.KmlLayer
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
+import org.json.JSONObject
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.database.model.Node
@@ -265,12 +268,7 @@ fun MapView(
}
}
- DisposableEffect(Unit) {
- onDispose {
- fusedLocationClient.removeLocationUpdates(locationCallback)
- mapViewModel.clearLoadedLayerData()
- }
- }
+ DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } }
val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
@@ -307,6 +305,7 @@ fun MapView(
}
}
+ val myNodeNum = mapViewModel.myNodeNum
val nodeClusterItems =
displayNodes.map { node ->
val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D)
@@ -315,6 +314,7 @@ fun MapView(
nodePosition = latLng,
nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
nodeSnippet = "${node.user.long_name}",
+ myNodeNum = myNodeNum,
)
}
val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
@@ -467,7 +467,11 @@ fun MapView(
) {
key(currentCustomTileProviderUrl) {
currentCustomTileProviderUrl?.let { url ->
- mapViewModel.createUrlTileProvider(url)?.let { tileProvider ->
+ val config =
+ mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
+ it.urlTemplate == url || it.localUri == url
+ }
+ mapViewModel.getTileProvider(config)?.let { tileProvider ->
TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
}
}
@@ -508,8 +512,15 @@ fun MapView(
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
val color = Color(focusedNode.colors.second).copy(alpha = alpha)
+ val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
+ val activeNodeZIndex = if (isHighPriority) 5f else 4f
+
if (index == sortedPositions.lastIndex) {
- MarkerComposable(state = markerState, zIndex = 4f) {
+ MarkerComposable(
+ state = markerState,
+ zIndex = activeNodeZIndex,
+ alpha = if (isHighPriority) 1.0f else 0.9f,
+ ) {
NodeChip(node = focusedNode)
}
} else {
@@ -590,34 +601,7 @@ fun MapView(
selectedWaypointId = selectedWaypointId,
)
- MapEffect(mapLayers) { map ->
- mapLayers.forEach { layerItem ->
- coroutineScope.launch {
- mapViewModel.loadMapLayerIfNeeded(map, layerItem)
- when (layerItem.layerType) {
- LayerType.KML -> {
- layerItem.kmlLayerData?.let { kmlLayer ->
- if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
- kmlLayer.addLayerToMap()
- } else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
- kmlLayer.removeLayerFromMap()
- }
- }
- }
-
- LayerType.GEOJSON -> {
- layerItem.geoJsonLayerData?.let { geoJsonLayer ->
- if (layerItem.isVisible && !geoJsonLayer.isLayerOnMap) {
- geoJsonLayer.addLayerToMap()
- } else if (!layerItem.isVisible && geoJsonLayer.isLayerOnMap) {
- geoJsonLayer.removeLayerFromMap()
- }
- }
- }
- }
- }
- }
- }
+ mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
}
ScaleBar(
@@ -651,6 +635,10 @@ fun MapView(
)
}
+ val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible }
+ val showRefresh = visibleNetworkLayers.isNotEmpty()
+ val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing }
+
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
mapFilterMenuExpanded = mapFilterMenuExpanded,
@@ -696,12 +684,22 @@ fun MapView(
}
},
followPhoneBearing = followPhoneBearing,
+ showRefresh = showRefresh,
+ isRefreshing = isRefreshingLayers,
+ onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() },
)
}
}
if (showLayersBottomSheet) {
ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
- CustomMapLayersSheet(mapLayers, onToggleVisibility, onRemoveLayer, onAddLayerClicked)
+ CustomMapLayersSheet(
+ mapLayers = mapLayers,
+ onToggleVisibility = onToggleVisibility,
+ onRemoveLayer = onRemoveLayer,
+ onAddLayerClicked = onAddLayerClicked,
+ onRefreshLayer = { mapViewModel.refreshMapLayer(it) },
+ onAddNetworkLayer = { name, url -> mapViewModel.addNetworkMapLayer(name, url) },
+ )
}
}
showClusterItemsDialog?.let {
@@ -721,6 +719,52 @@ fun MapView(
}
}
+@Composable
+private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) {
+ val context = LocalContext.current
+ var currentLayer by remember { mutableStateOf(null) }
+
+ MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
+ val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
+ val layer =
+ try {
+ when (layerItem.layerType) {
+ LayerType.KML -> KmlLayer(map, inputStream, context)
+ LayerType.GEOJSON ->
+ GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() }))
+ }
+ } catch (e: Exception) {
+ Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" }
+ null
+ }
+
+ layer?.let {
+ if (layerItem.isVisible) {
+ it.addLayerToMap()
+ }
+ currentLayer = it
+ }
+ }
+
+ DisposableEffect(layerItem.id) {
+ onDispose {
+ currentLayer?.removeLayerFromMap()
+ currentLayer = null
+ }
+ }
+
+ // Handle visibility changes without reloading the whole layer if possible,
+ // though KmlLayer.addLayerToMap() / removeLayerFromMap() is what we have.
+ LaunchedEffect(layerItem.isVisible) {
+ val layer = currentLayer ?: return@LaunchedEffect
+ if (layerItem.isVisible) {
+ if (!layer.isLayerOnMap) layer.addLayerToMap()
+ } else {
+ if (layer.isLayerOnMap) layer.removeLayerFromMap()
+ }
+ }
+}
+
internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
String(Character.toChars(unicodeCodePoint))
} catch (e: IllegalArgumentException) {
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
index 68f1b60f7..03a4cc8c5 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapViewModel.kt
@@ -23,15 +23,12 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
-import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType
-import com.google.maps.android.data.geojson.GeoJsonLayer
-import com.google.maps.android.data.kml.KmlLayer
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -46,7 +43,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
-import org.json.JSONObject
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.data.repository.CustomTileProviderRepository
import org.meshtastic.core.data.repository.NodeRepository
@@ -136,10 +132,14 @@ constructor(
.mapNotNull { it.config?.display?.units }
.stateInWhileSubscribed(initialValue = Config.DisplayConfig.DisplayUnits.METRIC)
- fun addCustomTileProvider(name: String, urlTemplate: String) {
+ fun addCustomTileProvider(name: String, urlTemplate: String, localUri: String? = null) {
viewModelScope.launch {
- if (name.isBlank() || urlTemplate.isBlank() || !isValidTileUrlTemplate(urlTemplate)) {
- _errorFlow.emit("Invalid name or URL template for custom tile provider.")
+ if (
+ name.isBlank() ||
+ (urlTemplate.isBlank() && localUri == null) ||
+ (localUri == null && !isValidTileUrlTemplate(urlTemplate))
+ ) {
+ _errorFlow.emit("Invalid name, URL template, or local URI for custom tile provider.")
return@launch
}
if (customTileProviderConfigs.value.any { it.name.equals(name, ignoreCase = true) }) {
@@ -147,7 +147,27 @@ constructor(
return@launch
}
- val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate)
+ var finalLocalUri = localUri
+ if (localUri != null) {
+ try {
+ val uri = Uri.parse(localUri)
+ val extension = "mbtiles"
+ val finalFileName = "mbtiles_${Uuid.random()}.$extension"
+ val copiedUri = copyFileToInternalStorage(uri, finalFileName)
+ if (copiedUri != null) {
+ finalLocalUri = copiedUri.toString()
+ } else {
+ _errorFlow.emit("Failed to copy MBTiles file to internal storage.")
+ return@launch
+ }
+ } catch (e: Exception) {
+ Logger.withTag("MapViewModel").e(e) { "Error processing local URI" }
+ _errorFlow.emit("Error processing local URI for MBTiles.")
+ return@launch
+ }
+ }
+
+ val newConfig = CustomTileProviderConfig(name = name, urlTemplate = urlTemplate, localUri = finalLocalUri)
customTileProviderRepository.addCustomTileProvider(newConfig)
}
}
@@ -156,10 +176,10 @@ constructor(
viewModelScope.launch {
if (
configToUpdate.name.isBlank() ||
- configToUpdate.urlTemplate.isBlank() ||
- !isValidTileUrlTemplate(configToUpdate.urlTemplate)
+ (configToUpdate.urlTemplate.isBlank() && configToUpdate.localUri == null) ||
+ (configToUpdate.localUri == null && !isValidTileUrlTemplate(configToUpdate.urlTemplate))
) {
- _errorFlow.emit("Invalid name or URL template for updating custom tile provider.")
+ _errorFlow.emit("Invalid name, URL template, or local URI for updating custom tile provider.")
return@launch
}
val existingConfigs = customTileProviderConfigs.value
@@ -195,29 +215,43 @@ constructor(
val configToRemove = customTileProviderRepository.getCustomTileProviderById(configId)
customTileProviderRepository.deleteCustomTileProvider(configId)
- if (configToRemove != null && _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate) {
- _selectedCustomTileProviderUrl.value = null
- // Also clear from prefs
- googleMapsPrefs.selectedCustomTileUrl = null
+ if (configToRemove != null) {
+ if (
+ _selectedCustomTileProviderUrl.value == configToRemove.urlTemplate ||
+ _selectedCustomTileProviderUrl.value == configToRemove.localUri
+ ) {
+ _selectedCustomTileProviderUrl.value = null
+ // Also clear from prefs
+ googleMapsPrefs.selectedCustomTileUrl = null
+ }
+
+ if (configToRemove.localUri != null) {
+ val uri = Uri.parse(configToRemove.localUri)
+ deleteFileToInternalStorage(uri)
+ }
}
}
}
fun selectCustomTileProvider(config: CustomTileProviderConfig?) {
if (config != null) {
- if (!isValidTileUrlTemplate(config.urlTemplate)) {
+ if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
_selectedCustomTileProviderUrl.value = null
googleMapsPrefs.selectedCustomTileUrl = null
return
}
- _selectedCustomTileProviderUrl.value = config.urlTemplate
- _selectedGoogleMapType.value = MapType.NORMAL // Reset to a default or keep last? For now, reset.
- googleMapsPrefs.selectedCustomTileUrl = config.urlTemplate
+ // Use localUri if present, otherwise urlTemplate
+ val selectedUrl = config.localUri ?: config.urlTemplate
+ _selectedCustomTileProviderUrl.value = selectedUrl
+ _selectedGoogleMapType.value = MapType.NONE
+ googleMapsPrefs.selectedCustomTileUrl = selectedUrl
googleMapsPrefs.selectedGoogleMapType = null
} else {
_selectedCustomTileProviderUrl.value = null
+ _selectedGoogleMapType.value = MapType.NORMAL
googleMapsPrefs.selectedCustomTileUrl = null
+ googleMapsPrefs.selectedGoogleMapType = MapType.NORMAL.name
}
}
@@ -228,27 +262,68 @@ constructor(
googleMapsPrefs.selectedCustomTileUrl = null
}
- fun createUrlTileProvider(urlString: String): TileProvider? {
- if (!isValidTileUrlTemplate(urlString)) {
- Logger.withTag("MapViewModel")
- .e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
+ private var currentTileProvider: TileProvider? = null
+
+ fun getTileProvider(config: CustomTileProviderConfig?): TileProvider? {
+ if (config == null) {
+ (currentTileProvider as? MBTilesProvider)?.close()
+ currentTileProvider = null
return null
}
- return object : UrlTileProvider(TILE_SIZE, TILE_SIZE) {
- override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
- val formattedUrl =
- urlString
- .replace("{z}", zoom.toString(), ignoreCase = true)
- .replace("{x}", x.toString(), ignoreCase = true)
- .replace("{y}", y.toString(), ignoreCase = true)
- return try {
- URL(formattedUrl)
- } catch (e: MalformedURLException) {
- Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" }
+
+ val selectedUrl = config.localUri ?: config.urlTemplate
+ if (currentTileProvider != null && _selectedCustomTileProviderUrl.value == selectedUrl) {
+ return currentTileProvider
+ }
+
+ // Close previous if it was a local provider
+ (currentTileProvider as? MBTilesProvider)?.close()
+
+ val newProvider =
+ if (config.isLocal) {
+ val uri = Uri.parse(config.localUri)
+ val file =
+ try {
+ uri.toFile()
+ } catch (e: Exception) {
+ File(uri.path ?: "")
+ }
+ if (file.exists()) {
+ MBTilesProvider(file)
+ } else {
+ Logger.withTag("MapViewModel").e("Local MBTiles file does not exist: ${config.localUri}")
null
}
+ } else {
+ val urlString = config.urlTemplate
+ if (!isValidTileUrlTemplate(urlString)) {
+ Logger.withTag("MapViewModel")
+ .e("Tile URL does not contain valid {x}, {y}, and {z} placeholders: $urlString")
+ null
+ } else {
+ object : UrlTileProvider(TILE_SIZE, TILE_SIZE) {
+ override fun getTileUrl(x: Int, y: Int, zoom: Int): URL? {
+ val subdomains = listOf("a", "b", "c")
+ val subdomain = subdomains[(x + y) % subdomains.size]
+ val formattedUrl =
+ urlString
+ .replace("{s}", subdomain, ignoreCase = true)
+ .replace("{z}", zoom.toString(), ignoreCase = true)
+ .replace("{x}", x.toString(), ignoreCase = true)
+ .replace("{y}", y.toString(), ignoreCase = true)
+ return try {
+ URL(formattedUrl)
+ } catch (e: MalformedURLException) {
+ Logger.withTag("MapViewModel").e(e) { "Malformed URL: $formattedUrl" }
+ null
+ }
+ }
+ }
+ }
}
- }
+
+ currentTileProvider = newProvider
+ return newProvider
}
private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.contains("{z}", ignoreCase = true) &&
@@ -296,7 +371,8 @@ constructor(
isValidTileUrlTemplate(savedCustomUrl)
) {
_selectedCustomTileProviderUrl.value = savedCustomUrl
- _selectedGoogleMapType.value = MapType.NORMAL // Default, as custom is active
+ _selectedGoogleMapType.value =
+ MapType.NONE // MapType.NONE to hide google basemap when using custom provider
} else {
// The saved custom URL is no longer valid or doesn't exist, remove preference
googleMapsPrefs.selectedCustomTileUrl = null
@@ -351,9 +427,34 @@ constructor(
null
}
}
- _mapLayers.value = loadedItems
- if (loadedItems.isNotEmpty()) {
- Logger.withTag("MapViewModel").i("Loaded ${loadedItems.size} persisted map layers.")
+
+ val networkItems =
+ googleMapsPrefs.networkMapLayers.mapNotNull { networkString ->
+ try {
+ val parts = networkString.split("|:|")
+ if (parts.size == 3) {
+ val id = parts[0]
+ val name = parts[1]
+ val uri = Uri.parse(parts[2])
+ MapLayerItem(
+ id = id,
+ name = name,
+ uri = uri,
+ isVisible = !hiddenLayerUrls.contains(uri.toString()),
+ layerType = LayerType.KML,
+ isNetwork = true,
+ )
+ } else {
+ null
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ _mapLayers.value = loadedItems + networkItems
+ if (_mapLayers.value.isNotEmpty()) {
+ Logger.withTag("MapViewModel").i("Loaded ${_mapLayers.value.size} persisted map layers.")
}
}
} else {
@@ -407,6 +508,37 @@ constructor(
}
}
+ fun addNetworkMapLayer(name: String, url: String) {
+ viewModelScope.launch {
+ if (name.isBlank() || url.isBlank()) {
+ _errorFlow.emit("Invalid name or URL for network layer.")
+ return@launch
+ }
+ try {
+ val uri = Uri.parse(url)
+ if (uri.scheme != "http" && uri.scheme != "https") {
+ _errorFlow.emit("URL must be http or https.")
+ return@launch
+ }
+
+ val path = uri.path?.lowercase() ?: ""
+ val layerType =
+ when {
+ path.endsWith(".geojson") || path.endsWith(".json") -> LayerType.GEOJSON
+ else -> LayerType.KML // Default to KML
+ }
+
+ val newItem = MapLayerItem(name = name, uri = uri, layerType = layerType, isNetwork = true)
+ _mapLayers.value = _mapLayers.value + newItem
+
+ val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
+ googleMapsPrefs.networkMapLayers = googleMapsPrefs.networkMapLayers + networkLayerString
+ } catch (e: Exception) {
+ _errorFlow.emit("Invalid URL.")
+ }
+ }
+ }
+
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
try {
val inputStream = application.contentResolver.openInputStream(uri)
@@ -450,19 +582,32 @@ constructor(
fun removeMapLayer(layerId: String) {
viewModelScope.launch {
val layerToRemove = _mapLayers.value.find { it.id == layerId }
- when (layerToRemove?.layerType) {
- LayerType.KML -> layerToRemove.kmlLayerData?.removeLayerFromMap()
- LayerType.GEOJSON -> layerToRemove.geoJsonLayerData?.removeLayerFromMap()
- null -> {}
- }
layerToRemove?.uri?.let { uri ->
- deleteFileToInternalStorage(uri)
+ if (layerToRemove.isNetwork) {
+ googleMapsPrefs.networkMapLayers =
+ googleMapsPrefs.networkMapLayers.filterNot { it.startsWith("$layerId|:|") }.toSet()
+ } else {
+ deleteFileToInternalStorage(uri)
+ }
googleMapsPrefs.hiddenLayerUrls -= uri.toString()
}
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}
}
+ fun refreshMapLayer(layerId: String) {
+ viewModelScope.launch {
+ _mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = true) else it } }
+ // By resetting the layer data in the UI (implied by just refreshing),
+ // we trigger a reload in the Composable.
+ _mapLayers.update { layers -> layers.map { if (it.id == layerId) it.copy(isRefreshing = false) else it } }
+ }
+ }
+
+ fun refreshAllVisibleNetworkLayers() {
+ _mapLayers.value.filter { it.isNetwork && it.isVisible }.forEach { refreshMapLayer(it.id) }
+ }
+
private suspend fun deleteFileToInternalStorage(uri: Uri) {
withContext(Dispatchers.IO) {
try {
@@ -477,70 +622,26 @@ constructor(
}
@Suppress("Recycle")
- private suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
+ suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
return withContext(Dispatchers.IO) {
try {
- application.contentResolver.openInputStream(uriToLoad)
- } catch (_: Exception) {
- Logger.d { "MapViewModel: Error opening InputStream from URI: $uriToLoad" }
+ if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
+ val url = java.net.URL(uriToLoad.toString())
+ java.io.BufferedInputStream(url.openStream())
+ } else {
+ application.contentResolver.openInputStream(uriToLoad)
+ }
+ } catch (e: Exception) {
+ Logger.withTag("MapViewModel").e(e) { "Error opening InputStream from URI: $uriToLoad" }
null
}
}
}
- suspend fun loadMapLayerIfNeeded(map: GoogleMap, layerItem: MapLayerItem) {
- if (layerItem.kmlLayerData != null || layerItem.geoJsonLayerData != null) return
- try {
- when (layerItem.layerType) {
- LayerType.KML -> loadKmlLayerIfNeeded(layerItem, map)
-
- LayerType.GEOJSON -> loadGeoJsonLayerIfNeeded(layerItem, map)
- }
- } catch (e: Exception) {
- Logger.withTag("MapViewModel").e(e) { "Error loading map layer for ${layerItem.uri}" }
- }
- }
-
- private suspend fun loadKmlLayerIfNeeded(layerItem: MapLayerItem, map: GoogleMap) {
- val kmlLayer =
- getInputStreamFromUri(layerItem)?.use {
- KmlLayer(map, it, application.applicationContext).apply {
- if (!layerItem.isVisible) removeLayerFromMap()
- }
- }
- _mapLayers.update { currentLayers ->
- currentLayers.map {
- if (it.id == layerItem.id) {
- it.copy(kmlLayerData = kmlLayer)
- } else {
- it
- }
- }
- }
- }
-
- private suspend fun loadGeoJsonLayerIfNeeded(layerItem: MapLayerItem, map: GoogleMap) {
- val geoJsonLayer =
- getInputStreamFromUri(layerItem)?.use { inputStream ->
- val jsonObject = JSONObject(inputStream.bufferedReader().use { it.readText() })
- GeoJsonLayer(map, jsonObject).apply { if (!layerItem.isVisible) removeLayerFromMap() }
- }
- _mapLayers.update { currentLayers ->
- currentLayers.map {
- if (it.id == layerItem.id) {
- it.copy(geoJsonLayerData = geoJsonLayer)
- } else {
- it
- }
- }
- }
- }
-
- fun clearLoadedLayerData() {
- _mapLayers.update { currentLayers ->
- currentLayers.map { it.copy(kmlLayerData = null, geoJsonLayerData = null) }
- }
+ override fun onCleared() {
+ super.onCleared()
+ (currentTileProvider as? MBTilesProvider)?.close()
}
}
@@ -553,8 +654,8 @@ data class MapLayerItem(
val id: String = Uuid.random().toString(),
val name: String,
val uri: Uri? = null,
- var isVisible: Boolean = true,
- var kmlLayerData: KmlLayer? = null,
- var geoJsonLayerData: GeoJsonLayer? = null,
+ val isVisible: Boolean = true,
val layerType: LayerType,
+ val isNetwork: Boolean = false,
+ val isRefreshing: Boolean = false,
)
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt
index 3a9875ac0..51c655f32 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomMapLayersSheet.kt
@@ -16,36 +16,55 @@
*/
package org.meshtastic.feature.map.component
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_layer
+import org.meshtastic.core.resources.add_network_layer
+import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.hide_layer
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_layer_formats
+import org.meshtastic.core.resources.name
+import org.meshtastic.core.resources.network_layer_url_hint
import org.meshtastic.core.resources.no_map_layers_loaded
+import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.remove_layer
+import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.show_layer
+import org.meshtastic.core.resources.url
+import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.map.MapLayerItem
@Suppress("LongMethod")
@@ -56,7 +75,10 @@ fun CustomMapLayersSheet(
onToggleVisibility: (String) -> Unit,
onRemoveLayer: (String) -> Unit,
onAddLayerClicked: () -> Unit,
+ onRefreshLayer: (String) -> Unit,
+ onAddNetworkLayer: (String, String) -> Unit,
) {
+ var showAddNetworkLayerDialog by remember { mutableStateOf(false) }
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
item {
Text(
@@ -87,7 +109,22 @@ fun CustomMapLayersSheet(
ListItem(
headlineContent = { Text(layer.name) },
trailingContent = {
- Row {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (layer.isNetwork) {
+ if (layer.isRefreshing) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp).padding(4.dp),
+ strokeWidth = 2.dp,
+ )
+ } else {
+ IconButton(onClick = { onRefreshLayer(layer.id) }) {
+ Icon(
+ imageVector = Icons.Filled.Refresh,
+ contentDescription = stringResource(Res.string.refresh),
+ )
+ }
+ }
+ }
IconButton(onClick = { onToggleVisibility(layer.id) }) {
Icon(
imageVector =
@@ -119,9 +156,57 @@ fun CustomMapLayersSheet(
}
}
item {
- Button(modifier = Modifier.fillMaxWidth().padding(16.dp), onClick = onAddLayerClicked) {
- Text(stringResource(Res.string.add_layer))
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(modifier = Modifier.fillMaxWidth(), onClick = onAddLayerClicked) {
+ Text(stringResource(Res.string.add_layer))
+ }
+ Button(modifier = Modifier.fillMaxWidth(), onClick = { showAddNetworkLayerDialog = true }) {
+ Text(stringResource(Res.string.add_network_layer))
+ }
}
}
}
+
+ if (showAddNetworkLayerDialog) {
+ AddNetworkLayerDialog(
+ onDismiss = { showAddNetworkLayerDialog = false },
+ onConfirm = { name, url ->
+ onAddNetworkLayer(name, url)
+ showAddNetworkLayerDialog = false
+ },
+ )
+ }
+}
+
+@Composable
+fun AddNetworkLayerDialog(onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
+ var name by remember { mutableStateOf("") }
+ var url by remember { mutableStateOf("") }
+
+ MeshtasticDialog(
+ onDismiss = onDismiss,
+ title = stringResource(Res.string.add_network_layer),
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text(stringResource(Res.string.name)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ label = { Text(stringResource(Res.string.url)) },
+ placeholder = { Text(stringResource(Res.string.network_layer_url_hint)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ },
+ onConfirm = { onConfirm(name, url) },
+ confirmTextRes = Res.string.save,
+ dismissTextRes = Res.string.cancel,
+ )
}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt
index c34eb86c5..e65f5968d 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/CustomTileProviderManagerSheet.kt
@@ -16,6 +16,9 @@
*/
package org.meshtastic.feature.map.component
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -51,9 +54,11 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_custom_tile_source
+import org.meshtastic.core.resources.add_local_mbtiles_file
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.delete_custom_tile_source
import org.meshtastic.core.resources.edit_custom_tile_source
+import org.meshtastic.core.resources.local_mbtiles_file
import org.meshtastic.core.resources.manage_custom_tile_sources
import org.meshtastic.core.resources.name
import org.meshtastic.core.resources.name_cannot_be_empty
@@ -76,6 +81,21 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
var showEditDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
+ val mbtilesPickerLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == android.app.Activity.RESULT_OK) {
+ result.data?.data?.let { uri ->
+ val fileName = uri.getFileName(context)
+ val baseName = fileName.substringBeforeLast('.')
+ mapViewModel.addCustomTileProvider(
+ name = baseName,
+ urlTemplate = "", // Empty for local
+ localUri = uri.toString(),
+ )
+ }
+ }
+ }
+
LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { errorMessage -> context.showToast(errorMessage) } }
if (showEditDialog) {
@@ -116,7 +136,16 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
items(customTileProviders, key = { it.id }) { config ->
ListItem(
headlineContent = { Text(config.name) },
- supportingContent = { Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall) },
+ supportingContent = {
+ if (config.isLocal) {
+ Text(
+ stringResource(Res.string.local_mbtiles_file),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ } else {
+ Text(config.urlTemplate, style = MaterialTheme.typography.bodySmall)
+ }
+ },
trailingContent = {
Row {
IconButton(
@@ -144,14 +173,30 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
}
item {
- Button(
- onClick = {
- editingConfig = null
- showEditDialog = true
- },
- modifier = Modifier.fillMaxWidth().padding(16.dp),
- ) {
- Text(stringResource(Res.string.add_custom_tile_source))
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(
+ onClick = {
+ editingConfig = null
+ showEditDialog = true
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(Res.string.add_custom_tile_source))
+ }
+
+ Button(
+ onClick = {
+ val intent =
+ Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "*/*"
+ }
+ mbtilesPickerLauncher.launch(intent)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringResource(Res.string.add_local_mbtiles_file))
+ }
}
}
}
@@ -262,3 +307,18 @@ private fun validateUrl(url: String, emptyUrlError: String, mustContainPlacehold
} else {
null
}
+
+private fun android.net.Uri.getFileName(context: android.content.Context): String {
+ var name = this.lastPathSegment ?: "mbtiles_file"
+ if (this.scheme == "content") {
+ context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
+ if (displayNameIndex != -1) {
+ name = cursor.getString(displayNameIndex)
+ }
+ }
+ }
+ }
+ return name
+}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
index 7ad618683..042e8c58f 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
@@ -17,26 +17,32 @@
package org.meshtastic.feature.map.component
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Navigation
+import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Map
import androidx.compose.material.icons.outlined.MyLocation
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.rounded.LocationDisabled
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.orient_north
+import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.map.MapViewModel
@@ -61,6 +67,9 @@ fun MapControlsOverlay(
bearing: Float = 0f,
onCompassClick: () -> Unit = {},
followPhoneBearing: Boolean,
+ showRefresh: Boolean = false,
+ isRefreshing: Boolean = false,
+ onRefresh: () -> Unit = {},
) {
HorizontalFloatingToolbar(
modifier = modifier,
@@ -115,6 +124,20 @@ fun MapControlsOverlay(
onClick = onManageLayersClicked,
)
+ if (showRefresh) {
+ if (isRefreshing) {
+ Box(modifier = Modifier.padding(8.dp)) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
+ }
+ } else {
+ MapButton(
+ icon = Icons.Filled.Refresh,
+ contentDescription = stringResource(Res.string.refresh),
+ onClick = onRefresh,
+ )
+ }
+ }
+
// Location tracking button
MapButton(
icon =
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
index 64f31d832..41c895c84 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt
@@ -89,7 +89,8 @@ fun NodeClusterMarkers(
}
}
}
- ClusteringMarkerProperties(zIndex = 1f)
+ // Use the item's own priority-based zIndex (5f for My Node/Favorites, 4f for others)
+ ClusteringMarkerProperties(zIndex = clusterItem.getZIndex())
},
)
}
diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt
index 796e2fcc7..1930438fc 100644
--- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt
+++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/model/NodeClusterItem.kt
@@ -20,15 +20,24 @@ import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.clustering.ClusterItem
import org.meshtastic.core.database.model.Node
-data class NodeClusterItem(val node: Node, val nodePosition: LatLng, val nodeTitle: String, val nodeSnippet: String) :
- ClusterItem {
+data class NodeClusterItem(
+ val node: Node,
+ val nodePosition: LatLng,
+ val nodeTitle: String,
+ val nodeSnippet: String,
+ val myNodeNum: Int? = null,
+) : ClusterItem {
override fun getPosition(): LatLng = nodePosition
override fun getTitle(): String = nodeTitle
override fun getSnippet(): String = nodeSnippet
- override fun getZIndex(): Float? = null
+ override fun getZIndex(): Float = when {
+ node.num == myNodeNum -> 5.0f // My node is always highest
+ node.isFavorite -> 5.0f // Favorites are equally high priority
+ else -> 4.0f
+ }
fun getPrecisionMeters(): Double? {
val precisionMap =
diff --git a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt
new file mode 100644
index 000000000..3f2b5b586
--- /dev/null
+++ b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MBTilesProviderTest.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.feature.map
+
+import android.database.sqlite.SQLiteDatabase
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+class MBTilesProviderTest {
+
+ @get:Rule val tempFolder = TemporaryFolder()
+
+ @Test
+ fun `getTile translates y coordinate correctly to TMS`() {
+ val dbFile = tempFolder.newFile("test.mbtiles")
+ setupMockDatabase(dbFile)
+
+ val provider = MBTilesProvider(dbFile)
+
+ // Google Maps zoom 1, x=0, y=0
+ // TMS y = (1 << 1) - 1 - 0 = 1
+ provider.getTile(0, 0, 1)
+
+ // We verify the query was correct by checking the database if we could,
+ // but here we just ensure it doesn't crash and returns the expected No Tile if missing.
+ // To truly test, we'd need to insert data.
+
+ val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
+ db.execSQL("INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (1, 0, 1, x'1234')")
+ db.close()
+
+ val tile = provider.getTile(0, 0, 1)
+ assertEquals(256, tile?.width)
+ assertEquals(256, tile?.height)
+ // Robolectric SQLite might return different blob handling, but let's see.
+ }
+
+ private fun setupMockDatabase(file: File) {
+ val db = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.CREATE_IF_NECESSARY)
+ db.execSQL("CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB)")
+ db.close()
+ }
+}
diff --git a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
new file mode 100644
index 000000000..571f3ac0d
--- /dev/null
+++ b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
@@ -0,0 +1,149 @@
+/*
+ * 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.feature.map
+
+import android.app.Application
+import android.net.Uri
+import androidx.lifecycle.SavedStateHandle
+import com.google.android.gms.maps.model.UrlTileProvider
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.data.model.CustomTileProviderConfig
+import org.meshtastic.core.data.repository.CustomTileProviderRepository
+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.datastore.UiPreferencesDataSource
+import org.meshtastic.core.prefs.map.GoogleMapsPrefs
+import org.meshtastic.core.prefs.map.MapPrefs
+import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.service.ServiceRepository
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+class MapViewModelTest {
+
+ private val application = mockk(relaxed = true)
+ private val mapPrefs = mockk(relaxed = true)
+ private val googleMapsPrefs = mockk(relaxed = true)
+ private val nodeRepository = mockk(relaxed = true)
+ private val packetRepository = mockk(relaxed = true)
+ private val radioConfigRepository = mockk(relaxed = true)
+ private val serviceRepository = mockk(relaxed = true)
+ private val customTileProviderRepository = mockk(relaxed = true)
+ private val uiPreferencesDataSource = mockk(relaxed = true)
+ private val savedStateHandle = SavedStateHandle(mapOf("waypointId" to null))
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ private lateinit var viewModel: MapViewModel
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ every { customTileProviderRepository.getCustomTileProviders() } returns flowOf(emptyList())
+ every { radioConfigRepository.deviceProfileFlow } returns flowOf(mockk(relaxed = true))
+ every { uiPreferencesDataSource.theme } returns MutableStateFlow(1)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.myId } returns MutableStateFlow(null)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+ every { nodeRepository.getNodes() } returns flowOf(emptyList())
+ every { packetRepository.getWaypoints() } returns flowOf(emptyList())
+ every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
+
+ viewModel =
+ MapViewModel(
+ application,
+ mapPrefs,
+ googleMapsPrefs,
+ nodeRepository,
+ packetRepository,
+ radioConfigRepository,
+ serviceRepository,
+ customTileProviderRepository,
+ uiPreferencesDataSource,
+ savedStateHandle,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `getTileProvider returns UrlTileProvider for remote config`() = runTest {
+ val config =
+ CustomTileProviderConfig(
+ name = "OpenStreetMap",
+ urlTemplate = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
+ )
+
+ val provider = viewModel.getTileProvider(config)
+ assertTrue(provider is UrlTileProvider)
+ }
+
+ @Test
+ fun `addNetworkMapLayer detects GeoJSON based on extension`() = runTest(testDispatcher) {
+ mockkStatic(Uri::class)
+ val mockUri = mockk()
+ every { Uri.parse("https://example.com/data.geojson") } returns mockUri
+ every { mockUri.scheme } returns "https"
+ every { mockUri.path } returns "/data.geojson"
+ every { mockUri.toString() } returns "https://example.com/data.geojson"
+
+ viewModel.addNetworkMapLayer("Test Layer", "https://example.com/data.geojson")
+ advanceUntilIdle()
+
+ val layer = viewModel.mapLayers.value.find { it.name == "Test Layer" }
+ assertEquals(LayerType.GEOJSON, layer?.layerType)
+ }
+
+ @Test
+ fun `addNetworkMapLayer defaults to KML for other extensions`() = runTest(testDispatcher) {
+ mockkStatic(Uri::class)
+ val mockUri = mockk()
+ every { Uri.parse("https://example.com/map.kml") } returns mockUri
+ every { mockUri.scheme } returns "https"
+ every { mockUri.path } returns "/map.kml"
+ every { mockUri.toString() } returns "https://example.com/map.kml"
+
+ viewModel.addNetworkMapLayer("Test KML", "https://example.com/map.kml")
+ advanceUntilIdle()
+
+ val layer = viewModel.mapLayers.value.find { it.name == "Test KML" }
+ assertEquals(LayerType.KML, layer?.layerType)
+ }
+}
From 6faa6e1c215e9cdad651973cb744562127a464af Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 27 Feb 2026 08:49:53 -0600
Subject: [PATCH 029/474] chore(deps): update fastlane to v2.232.2 (#4663)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Gemfile.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 095a491e4..de497cc4a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -43,7 +43,7 @@ GEM
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
- faraday (1.10.4)
+ faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@@ -72,7 +72,7 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
- fastlane (2.232.1)
+ fastlane (2.232.2)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
From 8a3763b2ff728911a3656207a999454577e0e6d6 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 09:08:11 -0600
Subject: [PATCH 030/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4662)
---
.../composeResources/values-et/strings.xml | 1 +
.../composeResources/values-fi/strings.xml | 1 +
.../composeResources/values-fr/strings.xml | 75 +++++++++++++++++++
.../composeResources/values-ja/strings.xml | 7 ++
.../values-zh-rCN/strings.xml | 1 +
.../values-zh-rTW/strings.xml | 67 +++++++++--------
.../android/fr-FR/full_description.txt | 12 +--
7 files changed, 125 insertions(+), 39 deletions(-)
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index e705b952a..c9f586e7f 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -714,6 +714,7 @@
Täis nimi
Lühi nimi
Seadme mudel
+ Litsentseeritud raadioamatöör (Ham)
Selle valiku lubamine keelab krüpteerimise ja ei ühildu Meshtastic vaikevõrguga.
Kastepunkt
Õhurõhk
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 846967343..4d9d6143d 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -714,6 +714,7 @@
Pitkä nimi
Lyhytnimi
Laitteen malli
+ Lisensoitu radioamatööri (HAM)
Jos otat tämän asetuksen käyttöön, salaus poistetaan käytöstä, eikä laite ole enää yhteensopiva oletusasetuksilla toimivan Meshtastic-verkon kanssa.
Kastepiste
Ilmanpaine
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index 68e89ee88..048fabbe9 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -37,6 +37,9 @@
Dernière écoute
via MQTT
via MQTT
+ via UDP
+ via API
+ Interne
par Favoris
Afficher uniquement les nœuds ignorés
Non reconnu
@@ -147,15 +150,19 @@
Paquet de position
Intervalle de diffusion
Position Intelligente
+ Intervalle intelligent
+ Distance intelligente
Appareil GPS
Position fixe
Altitude
+ Fréquence de récupération GPS
Appareil GPS avancé
GPIO réception du GPS
GPIO émission du GPS
GPIO EN du GPS
GPIO
Debug
+ Ch
Nom du canal
Code QR
Nom d'Utilisateur inconnu
@@ -258,6 +265,7 @@
Valeur par défaut du système
Choisir un thème
Fournir l'emplacement au maillage
+ Encodage compact pour Cyrillique
- Supprimer le message ?
- Supprimer %1$s messages ?
@@ -355,6 +363,12 @@
Format du code QR d'identification WiFi invalide
Précédent
Batterie
+ UtilCanal
+ UtilAir
+ Temp
+ Hum
+ Temp sol
+ Hum sol
Journaux
Sauts
Sauts : %1$d
@@ -408,11 +422,13 @@
%1$s - %2$s
Route aller :\n\n
Route retour :\n\n
+ 1H
24H
48H
1S
2S
4S
+ 1M
Max
Age inconnu
Copier
@@ -436,6 +452,7 @@
Notifications de batterie faible
Batterie faible : %1$s
Notifications de batterie faible (nœuds favoris)
+ Baro
Activé
Diffusion UDP
Configuration UDP
@@ -517,6 +534,7 @@
Diffusion de l'État (secondes)
Envoyer une sonnerie avec un message d'alerte
Nom convivial
+ Adresse conviviale
Broche GPIO à surveiller
Type du déclencheur de détection
Utiliser le mode INPUT_PULLUP
@@ -605,6 +623,7 @@
WiFi activé
SSID
PSK (clé)
+ Obtenir le document
Options Ethernet
Ethernet activé
Serveur NTP
@@ -612,6 +631,7 @@
Mode IPv4
IP
Passerelle
+ Subred
Configuration du Paxcounter
Paxcounter activé
Statut du message
@@ -694,6 +714,7 @@
Nom long
Nom court
Modèle de matériel
+ Radioamateur licencié (RA)
L'activation de cette option désactive le chiffrement et n'est pas compatible avec le réseau Meshtastic par défaut.
Point de rosée
Pression
@@ -851,6 +872,7 @@
PAX
Aucune métrique PAX disponible.
Périphériques WiFi
+ Appareils Bluetooth
Périphériques appairés
Périphérique connecté
Limite de débit dépassée. Veuillez réessayer plus tard.
@@ -863,6 +885,7 @@
Version du micrologiciel
Périphériques réseaux récents
Appareils réseau découverts
+ Périphériques Bluetooth disponibles
Commencer
Bienvenue sur
Restez connecté n'importe où
@@ -878,6 +901,7 @@
Notifications pour les nouveaux nœuds découverts.
Batterie faible
Notifications d'alertes de batterie faible pour l'appareil connecté.
+ Sélectionnez les paquets envoyés comme critiques ignorera le commutateur muet et les paramètres Ne pas déranger dans le centre de notification du système d'exploitation.
Configurer les autorisations de notification
Localisation du téléphone
Meshtastic utilise la localisation de votre téléphone pour activer un certain nombre de fonctionnalités. Vous pouvez mettre à jour vos autorisations de localisation à tout moment à partir des paramètres.
@@ -951,9 +975,14 @@
Paramètres système
Pas de stats disponibles
Les statistiques sont collectées pour nous aider à améliorer l'application Android (merci), nous recevrons des informations anonymes sur le comportement de l'utilisateur. Cela inclut les rapports de plantage, les écrans utilisés dans l'application, etc.
+ Plateformes d'analyse :
Pour plus d'informations, consultez notre politique de confidentialité.
Non défini - 0
Relayé par : %1$s
+
+ - Entendu par %1$d relai
+ - Entendu par %1$d relais
+
%1$s est généralement livré avec un chargeur d'amorçage qui ne prend pas en charge les mises à jour via Bluetooth (OTA ou Over The Air). Vous devrez peut-être flasher un chargeur d'amorçage compatible OTA via USB avant de flasher OTA.
En savoir plus
Pour le RAK WisBlock RAK4631, utilisez l'outil DFU série du fournisseur (par exemple, adafruit-nrfutil dfu serial avec le fichier .zip du bootloader fourni). La copie du fichier .uf2 seul ne permettra pas de mettre à jour le bootloader.
@@ -1001,6 +1030,7 @@
Chirpy
Redémarrage en mode DFU...
Attente du périphérique en mode DFU...
+ Yeah ! Attendez, copie du firmware...
Veuillez enregistrer le fichier .uf2 sur le lecteur DFU de votre appareil.
Flash de l'appareil, veuillez patienter...
Transfert de fichier USB
@@ -1039,6 +1069,14 @@
Retour
Désactivé
En permanence
+
+ - %1$d seconde
+ - %1$d secondes
+
+
+ - %1$d minute
+ - %1$d minutes
+
- %1$d heure
- %1$d heures
@@ -1078,8 +1116,45 @@
Filtré
Activer le filtrage
Désactiver le filtrage
+ URL du canal
+ Scan NFC
+ Scanner le contact partagé NFC
+ Scanner le code QR du contact partagé
+ Entrez l'URL du contact partagé
+ Scanner les canaux NFC
+ Scanner les canaux QR Code
+ Entrer l'URL du canal
+ Partager le code QR des canaux
+ Approchez votre appareil près de la balise NFC à scanner.
Générer un QR Code
+ Le NFC est désactivé. Veuillez l'activer dans les paramètres du système.
Tout
Bluetooth
+ Configurer les autorisations Bluetooth
+ Se connecter à la radio
+ Recherchez et connectez-vous à votre périphérique radio maillage Meshtastic.
+ Découverte
+ Trouvez et identifiez les dispositifs Meshtastic autour de vous.
Configuration
+ Gérer à distance sans fil les paramètres et les canaux de votre appareil.
+ Autorisation accordée
+ Autorisation refusée
+ Sélection du style de carte
+ Batterie: %1$d%%
+ Nœuds : %1$d en ligne / %2$d au total
+ Temps de disponibilité : %1$s
+ UtilCanal: %1$.2f%% | UtilAir: %2$.2f%%
+ Trafic : TX %1$d / RX %2$d (D: %3$d)
+ Relais : %1$d (annulé: %2$d)
+ Diagnostiques : %1$s
+ Bruit %1$d dBm
+ Mauvais %1$d
+ Abandonné %1$d
+ Pile
+ %1$d / %2$d
+ %1$s
+ Alimenté
+ Statistiques Meshtastiques
+ Actualiser
+ Mis à jour
diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
index 0e97438b5..165d54b2f 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -21,7 +21,11 @@
Meshtastic
絞り込み
ノードフィルターをクリアします
+ 絞り込み
不明なものを含める
+ インフラを除外
+ オフラインノードを非表示
+ ダイレクトノードのみ表示
詳細を表示
並べ替え
ノードの並べ替えオプション
@@ -33,6 +37,8 @@
MQTT経由
MQTT経由
UDP 経由
+ API 経由
+ 内部
お気に入り経由
不明
相手の受信確認待ち
@@ -460,6 +466,7 @@
ユーザー設定
ノード ID
ハードウェアのモデル
+ アマチュア無線従事者 (ハム/HAM)
このオプションを有効にすると、暗号化が無効になりデフォルトのMeshtasticネットワークと互換性が無くなります。
露点
気圧
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index 60f86367c..d89f6a908 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -712,6 +712,7 @@
长名称
短名称
硬件型号
+ 业余无线电模式(HAM)
启用此选项将禁用加密并且不兼容默认的Meshtastic网络。
结露点
气压
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index 9287780de..484a72595 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -710,6 +710,7 @@
裝置長名稱
裝置短名稱
硬體型號
+ 領有執照的業餘無線電台 (HAM)
啟用此選項將停用訊息加密,並與預設的 Meshtastic 網路不相容。
露點
氣壓
@@ -814,7 +815,7 @@
模組已解鎖
模組已解鎖
遠端
- (線上 %1$d / 顯示 %2$d / 總數 %3$d)
+ (線上 %1$d / 顯示 %2$d / 總計 %3$d)
回應
中斷連線
找不到網路裝置。
@@ -982,8 +983,8 @@
%1$s 裝置出廠時預載的開機載入程式通常不支援 OTA 更新功能。在執行 OTA 韌體更新前,您可能需要先透過 USB 連線刷入具備 OTA 功能的開機載入程式。
瞭解詳情
- 針對 RAK WisBlock RAK4631 裝置,必須使用 ' 提供的序列埠 DFU(裝置韌體更新)工具進行更新。舉例來說,可以使用 adafruit-nrfutil dfu serial 命令配合提供的 bootloader .zip 壓縮檔。注意:單純複製 .uf2 檔案並不會更新開機載入程式。
- ' 此裝置不再顯示
+ 針對 RAK WisBlock RAK4631 裝置,必須使用 ' 提供的序列埠 DFU(裝置韌體更新)工具進行更新。舉例來說,可以使用 adafruit-nrfutil dfu serial 命令配合提供的 bootloader .zip 壓縮檔。注意:單純複製 .uf2 檔案並不會更新開機載入程式。
+ ' 此裝置不再顯示
保留我的最愛?
USB 裝置
@@ -995,28 +996,28 @@
穩定版
Alpha 測試版
注意:更新期間將會暫時中斷您的裝置連線。
- 正在下載韌體... %1$d%
+ 正在下載韌體⋯⋯ %1$d%
錯誤: %1$s
重試
更新成功!
完成
- 正在啟動 DFU...
- %1$s 更新中...
- 正在啟用 DFU 模式...
- 正在驗證韌體...
- 正在中斷連線...
+ 正在啟動 DFU⋯⋯
+ %1$s 更新中⋯⋯
+ 正在啟用 DFU 模式⋯⋯
+ 正在驗證韌體⋯⋯
+ 正在中斷連線⋯⋯
未知的硬體型號: %1$d
%1$s 連線裝置無效或無法識別其藍牙位址。
尚未連線裝置
在發行版本中找不到 %1$s 的韌體。
- 正在解壓縮韌體...
- 正在中斷連線以啟動 DFU 服務...
+ 正在解壓縮韌體⋯⋯
+ 正在中斷連線以啟動 DFU 服務⋯⋯
更新失敗
- 請稍候,正在處理中...
+ 請稍候,正在處理中⋯⋯
請確保裝置在手機附近。
請不要關閉這個應用程式。
- 即將完成...
- 處理中,請稍候...
+ 即將完成⋯⋯
+ 處理中,請稍候⋯⋯
選擇本機檔案
本機檔案
來源: 本機檔案
@@ -1025,20 +1026,20 @@
您即將為裝置刷入新韌體,此過程存在風險。\n\n 請確保裝置電量充足。\n 請將裝置保持在手機附近。\n 更新期間請勿關閉應用程式。\n\n 請確認您已為您的硬體選擇正確的韌體。
Chirpy 小提醒:「別忘了準備梯子!」
Chirpy
- 正在進入 DFU 模式...
- 等待裝置進入 DFU 模式...
+ 正在進入 DFU 模式⋯⋯
+ 等待裝置進入 DFU 模式⋯⋯
正在複製韌體⋯⋯記得要強調是史上最快喔!
- 請將 .uf2 檔案儲存到您 ' 裝置 DFU 磁碟機。
- 刷入韌體中,請稍等...
+ 請將 .uf2 檔案儲存到您 ' 裝置 DFU 磁碟機。
+ 刷入韌體中,請稍等⋯⋯
USB 檔案傳輸
BLE OTA
WiFi OTA
更新方式 %1$s
選擇 DFU USB 磁碟機
您的裝置已重新啟動進入 DFU 模式,應該會顯示為 USB 磁碟機(例如:RAK4631)。\n\n 當檔案選擇器開啟時,請選擇該磁碟機的根目錄以儲存韌體檔案。
- 正在驗證更新...
+ 正在驗證更新⋯⋯
驗證逾時。裝置未能在時限內重新連線。
- 等待裝置重新連線...
+ 等待裝置重新連線⋯⋯
目標裝置: %1$s
版本說明
未知錯誤
@@ -1046,34 +1047,34 @@
DFU錯誤: %1$s
DFU 已中止
缺少節點使用者資訊。
- 電量過低 (%1$d%),請在更新前為您的裝置充電。
+ 電量過低 (%1$d%%),請在更新前為您的裝置充電。
無法取得韌體檔案。
Nordic DFU 更新失敗
USB 更新失敗
韌體雜湊值遭拒。裝置可能需要雜湊值配置或開機載入程式更新。
OTA 更新失敗: %1$s
- Loading firmware...
- 等待裝置重新啟動至 OTA 模式...
- 正在連線至裝置(第 %1$d / %2$d次嘗試 )...
- 正在檢查裝置版本...
- 正在啟動 OTA 更新...
- 正在上傳韌體...
- 正在上傳韌體... %1$d% (%2$s)
- 正在重新啟動裝置...
+ Loading firmware⋯⋯
+ 等待裝置重新啟動至 OTA 模式⋯⋯
+ 正在連線至裝置(第 %1$d / %2$d次嘗試 )⋯⋯
+ 正在檢查裝置版本⋯⋯
+ 正在啟動 OTA 更新⋯⋯
+ 正在上傳韌體⋯⋯
+ 正在上傳韌體⋯⋯ %1$d% (%2$s)
+ 正在重新啟動裝置⋯⋯
韌體更新
韌體更新狀態
- 正在清除...
+ 正在清除⋯⋯
返回
取消設定
保持開啟
- - 1秒
+ - %1$d 秒
- - 1分鐘
+ - %1$d 分鐘
- - 1小時
+ - %1$d 小時
指南針
diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt
index dd88d682a..ecd439b00 100644
--- a/fastlane/metadata/android/fr-FR/full_description.txt
+++ b/fastlane/metadata/android/fr-FR/full_description.txt
@@ -1,21 +1,21 @@
Meshtastic est un outil permettant d'utiliser des appareils Android avec des radios open-source, hors ligne, utilisant un réseau maillé. Cette application est le client principal du projet Meshtastic, vous permettant de gérer vos périphériques maillés et de communiquer avec d'autres utilisateurs.
-For more information about the Meshtastic project, please visit our website: meshtastic.org. The firmware that runs on the radio devices is a separate open-source project, which you can find here: https://github.com/meshtastic/Meshtastic-device.
+Pour plus d'informations sur le projet Meshtastic, veuillez visiter notre site Web : [https://www.meshtastic.org](). Le microprogramme/firmware qui fonctionne sur les appareils radio est un projet open-source séparé, que vous pouvez trouver ici : [](https://github.com/meshtastic/Meshtastic-device).
-Community and Support
+Communauté et Support
Ce projet est actuellement en version bêta. Vos commentaires sont les bienvenus ! Si vous avez des questions, des commentaires ou si vous rencontrez des problèmes, veuillez rejoindre notre communauté amicale et active :
-• Discussion Forum: https://github.com/orgs/meshtastic/discussions
+• Forum de Discussion : https://github.com/orgs/meshtastic/discussions
• Discord: https://discord.gg/meshtastic
-• Report an Issue: https://github.com/meshtastic/Meshtastic-Android/issues
+• Signaler un problème : https://github.com/meshtastic/Meshtastic-Android/issues
Documentation
Pour en savoir plus sur les fonctionnalités et les capacités de cette application et Meshtastic, veuillez consulter notre documentation officielle :
-View Documentation
+Voir la documentation
-Translations
+Traductions
Vous pouvez aider à traduire l'application dans votre langue maternelle en utilisant Crowdin :
https://crowdin.meshtastic.org/android
From 2e64edf561622b9a4bbe777e70b92e0360741631 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 27 Feb 2026 15:11:08 +0000
Subject: [PATCH 031/474] chore(release): prepare v2.7.14-internal.2 [skip ci]
- Bump base version to 2.7.14
- Sync translations and assets
---
app/src/main/assets/firmware_releases.json | 6 ------
.../src/commonMain/composeResources/values-ar/strings.xml | 1 +
.../src/commonMain/composeResources/values-be/strings.xml | 1 +
.../src/commonMain/composeResources/values-bg/strings.xml | 3 +--
.../src/commonMain/composeResources/values-ca/strings.xml | 1 +
.../src/commonMain/composeResources/values-cs/strings.xml | 7 +------
.../src/commonMain/composeResources/values-de/strings.xml | 7 +------
.../src/commonMain/composeResources/values-el/strings.xml | 1 +
.../src/commonMain/composeResources/values-es/strings.xml | 3 +--
.../src/commonMain/composeResources/values-et/strings.xml | 7 +------
.../src/commonMain/composeResources/values-fi/strings.xml | 7 +------
.../src/commonMain/composeResources/values-fr/strings.xml | 7 +------
.../src/commonMain/composeResources/values-ga/strings.xml | 1 +
.../src/commonMain/composeResources/values-gl/strings.xml | 1 +
.../src/commonMain/composeResources/values-he/strings.xml | 1 +
.../src/commonMain/composeResources/values-hr/strings.xml | 1 +
.../src/commonMain/composeResources/values-ht/strings.xml | 1 +
.../src/commonMain/composeResources/values-hu/strings.xml | 7 +------
.../src/commonMain/composeResources/values-is/strings.xml | 1 +
.../src/commonMain/composeResources/values-it/strings.xml | 7 +------
.../src/commonMain/composeResources/values-ja/strings.xml | 1 +
.../src/commonMain/composeResources/values-ko/strings.xml | 1 +
.../src/commonMain/composeResources/values-lt/strings.xml | 1 +
.../src/commonMain/composeResources/values-nl/strings.xml | 1 +
.../src/commonMain/composeResources/values-no/strings.xml | 1 +
.../src/commonMain/composeResources/values-pl/strings.xml | 1 +
.../commonMain/composeResources/values-pt-rBR/strings.xml | 6 +-----
.../src/commonMain/composeResources/values-pt/strings.xml | 1 +
.../src/commonMain/composeResources/values-ro/strings.xml | 1 +
.../src/commonMain/composeResources/values-ru/strings.xml | 7 +------
.../src/commonMain/composeResources/values-sk/strings.xml | 1 +
.../src/commonMain/composeResources/values-sl/strings.xml | 1 +
.../src/commonMain/composeResources/values-sq/strings.xml | 1 +
.../src/commonMain/composeResources/values-sr/strings.xml | 1 +
.../src/commonMain/composeResources/values-srp/strings.xml | 1 +
.../src/commonMain/composeResources/values-sv/strings.xml | 7 +------
.../src/commonMain/composeResources/values-tr/strings.xml | 1 +
.../src/commonMain/composeResources/values-uk/strings.xml | 1 +
.../commonMain/composeResources/values-zh-rCN/strings.xml | 7 +------
.../commonMain/composeResources/values-zh-rTW/strings.xml | 7 +------
40 files changed, 39 insertions(+), 81 deletions(-)
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index dcb81f56d..01aeacbf8 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -199,12 +199,6 @@
"title": "Add VL53L0 distance sensor.",
"page_url": "https://github.com/meshtastic/firmware/pull/9706",
"zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9675",
- "title": "add FromRadioSync BLE characteristic",
- "page_url": "https://github.com/meshtastic/firmware/pull/9675",
- "zip_url": "https://discord.com/invite/meshtastic"
}
]
}
\ No newline at end of file
diff --git a/core/resources/src/commonMain/composeResources/values-ar/strings.xml b/core/resources/src/commonMain/composeResources/values-ar/strings.xml
index 339c04c9d..bc476eb1c 100644
--- a/core/resources/src/commonMain/composeResources/values-ar/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ar/strings.xml
@@ -185,4 +185,5 @@
إعدادات بلوتوث
+
diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml
index 43d80a240..9690b226f 100644
--- a/core/resources/src/commonMain/composeResources/values-be/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml
@@ -244,4 +244,5 @@
Усе
Bluetooth
+
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index 625f903e4..1444d7c83 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -720,9 +720,7 @@
Терен
Хибриден
Управление на слоевете на картата
- Персонализираните слоеве поддържат .kml, .kmz или GeoJSON файлове.
Слоеве на картата
- Няма заредени персонализирани слоеве.
Добавяне на слой
Скриване на слоя
Показване на слой
@@ -866,4 +864,5 @@
Всички
Bluetooth
Конфигурация
+
diff --git a/core/resources/src/commonMain/composeResources/values-ca/strings.xml b/core/resources/src/commonMain/composeResources/values-ca/strings.xml
index 8e0fc5a86..7906a4c21 100644
--- a/core/resources/src/commonMain/composeResources/values-ca/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ca/strings.xml
@@ -206,4 +206,5 @@
No configurat
+
diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
index 43df26ca7..cebaeb12c 100644
--- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
@@ -805,9 +805,7 @@
Terénní
Hybridní
Správa vrstev mapy
- Vlastní vrstvy podporují soubory .kml, .kmz nebo GeoJSON.
Mapové vrstvy
- Žádné vlastní vrstvy nenačteny.
Přidat vrstvu
Skrýt vrstvu
Zobrazit vrstvu
@@ -815,10 +813,6 @@
Přidat vrstvu
Uzly na tomto místě
Správa vlastních zdrojů dlaždic
- Přidat vlastní zdroj dlaždic
- Žádné vlastní zdroje dlaždic
- Upravit vlastní zdroj dlaždic
- Odstranit vlastní zdroj dlaždic
Adresa URL nesmí být prázdná.
URL šablona
bod trasy
@@ -967,4 +961,5 @@
NFC je zakázáno. Povolte jej v nastavení systému.
Vše
Bluetooth
+
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index f5311fe9a..c64989258 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -932,9 +932,7 @@
Gelände
Hybrid
Kartenebenen verwalten
- Benutzerdefinierte Ebenen unterstützen .kml, .kmz oder GeoJSON Dateien.
Kartenebenen
- Keine benutzerdefinierten Ebenen geladen.
Ebene hinzufügen
Ebene ausblenden
Ebene anzeigen
@@ -943,10 +941,6 @@
Knoten an diesem Standort
Ausgewählter Kartentyp
Benutzerdefinierte Kachelquellen verwalten
- Benutzerdefinierte Kachelquelle hinzufügen
- Keine benutzerdefinierten Kachelquellen
- Benutzerdefinierte Kachelquelle bearbeiten
- Benutzerdefinierte Kachelquelle löschen
Name darf nicht leer sein.
Der Name des Anbieters existiert bereits.
URL darf nicht leer sein.
@@ -1152,4 +1146,5 @@
Fehlerhaft %1$d
Verworfen %1$d
Angeschaltet
+
diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml
index bb6860124..6b59874e3 100644
--- a/core/resources/src/commonMain/composeResources/values-el/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml
@@ -206,4 +206,5 @@
Bluetooth
+
diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml
index cfded8cda..56e33a948 100644
--- a/core/resources/src/commonMain/composeResources/values-es/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml
@@ -832,9 +832,7 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m
Terreno
Híbrido
Administrar capas de mapa
- Las capas personalizadas soportan archivos .kml, .kmz, o GeoJSON.
Capas del mapa
- No se cargaron capas personalizadas.
Añadir capa
Ocultar capa
Mostrar capa
@@ -912,4 +910,5 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m
Todos
Bluetooth
Configuración
+
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index c9f586e7f..ef364a6b2 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -933,9 +933,7 @@
Maastik
Hübriid
Halda kaardikihte
- Kohandatud kihid toetavad .kml, .kmz või GeoJSON-faile.
Kaardikihid
- Kaardikihid laadimata.
Lisa kiht
Peida kiht
Näita kiht
@@ -944,10 +942,6 @@
Sõlmed siin asukohas
Vali kaardi tüüp
Halda kohandatud kardikihti
- Lisa kohandatud kardikiht
- Kohandatud kardikihte ei ole
- Muuda kohandatud kardikihti
- Kustuta kohandatud kardikiht
Nimi ei tohi olla tühi.
Teenusepakkuja nimi on olemas.
URL ei tohi olla tühi.
@@ -1160,4 +1154,5 @@
Meshtasticu statistika
Värskenda
Uuendatud
+
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 4d9d6143d..52c37cba5 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -933,9 +933,7 @@
Maasto
Hybridi
Hallitse Karttatasoja
- Mukautetut karttatasot tukevat .kml-, .kmz- tai GeoJSON-tiedostoja.
Karttatasot
- Mukautettuja karttatasoja ei ladattu.
Lisää taso
Piilota taso
Näytä taso
@@ -944,10 +942,6 @@
Laitteet tässä sijainnissa
Valittu karttatyyppi
Hallitse mukautettuja karttatasoja
- Lisää mukautettu karttataso
- Ei mukautettuja karttatasoja
- Muokkaa mukautettua karttatasoa
- Poista mukautettu karttataso
Nimi ei voi olla tyhjä.
Palveluntarjoajan nimi on olemassa.
URL-osoite ei voi olla tyhjä.
@@ -1161,4 +1155,5 @@
Meshtastic tilastot
Päivitä
Päivitetty
+
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index 048fabbe9..3a252b924 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -930,9 +930,7 @@
Terrain
Hybride
Gérer les calques de la carte
- Les calques personnalisés prennent en charge les fichiers .kml, .kmz ou GeoJSON.
Couches cartographiques
- Aucun calque personnalisé chargé.
Ajouter un calque
Ajouter un calque
Afficher le calque
@@ -941,10 +939,6 @@
Nœuds à cet emplacement
Type de carte sélectionné
Gérer les sources de tuiles personnalisées
- Ajouter une source de tuile personnalisée
- Aucune source de tuiles personnalisée
- Modifier la source de tuile personnalisée
- Supprimer la source de tuile personnalisée
Le nom ne peut pas être vide.
Le nom du fournisseur existe déjà.
URL ne peut être vide.
@@ -1157,4 +1151,5 @@
Statistiques Meshtastiques
Actualiser
Mis à jour
+
diff --git a/core/resources/src/commonMain/composeResources/values-ga/strings.xml b/core/resources/src/commonMain/composeResources/values-ga/strings.xml
index 80f1754b3..7d54eeaf7 100644
--- a/core/resources/src/commonMain/composeResources/values-ga/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ga/strings.xml
@@ -237,4 +237,5 @@
Díshocraigh
+
diff --git a/core/resources/src/commonMain/composeResources/values-gl/strings.xml b/core/resources/src/commonMain/composeResources/values-gl/strings.xml
index 05c9db7b6..db8942d96 100644
--- a/core/resources/src/commonMain/composeResources/values-gl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-gl/strings.xml
@@ -171,4 +171,5 @@
Sen configurar
+
diff --git a/core/resources/src/commonMain/composeResources/values-he/strings.xml b/core/resources/src/commonMain/composeResources/values-he/strings.xml
index 38b6b6664..023dc19b6 100644
--- a/core/resources/src/commonMain/composeResources/values-he/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-he/strings.xml
@@ -153,4 +153,5 @@
לא מוגדר
+
diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
index f29bf910a..11fad4638 100644
--- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
@@ -172,4 +172,5 @@
Nepostavljeno
+
diff --git a/core/resources/src/commonMain/composeResources/values-ht/strings.xml b/core/resources/src/commonMain/composeResources/values-ht/strings.xml
index d517a2e45..96fb67155 100644
--- a/core/resources/src/commonMain/composeResources/values-ht/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ht/strings.xml
@@ -225,4 +225,5 @@
Pa konfigire
+
diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
index ac09eeb84..9b6088405 100644
--- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
@@ -873,9 +873,7 @@
Domborzat
Hibrid
Térképrétegek kezelése
- Az egyéni rétegek .kml, .kmz vagy GeoJSON fájlokat támogatnak.
Térképrétegek
- Nincsenek betöltött egyéni rétegek.
Réteg hozzáadása
Réteg elrejtése
Réteg megjelenítése
@@ -884,10 +882,6 @@
Csomópontok ezen a helyen
Kiválasztott térképtípus
Egyéni csempeforrások kezelése
- Egyéni csempeforrás hozzáadása
- Nincsenek egyéni csempeforrások
- Egyéni csempeforrás szerkesztése
- Egyéni csempeforrás törlése
A név nem lehet üres.
A szolgáltató neve már létezik.
Az URL nem lehet üres.
@@ -933,4 +927,5 @@
Összes
Bluetooth
+
diff --git a/core/resources/src/commonMain/composeResources/values-is/strings.xml b/core/resources/src/commonMain/composeResources/values-is/strings.xml
index 11c3f48f2..daba83eb5 100644
--- a/core/resources/src/commonMain/composeResources/values-is/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-is/strings.xml
@@ -136,4 +136,5 @@
Óstillt
+
diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml
index 34bd80c84..6a734a54a 100644
--- a/core/resources/src/commonMain/composeResources/values-it/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml
@@ -838,9 +838,7 @@
Terreno
Ibrido
Gestisci livelli della mappa
- I livelli personalizzati supportano file .kml, .kmz o GeoJSON.
Livelli della mappa
- Nessun livello personalizzato caricato.
Aggiungi livello
Nascondi livello
Mostra livello
@@ -849,10 +847,6 @@
Nodi in questa posizione
Tipo di mappa selezionata
Gestisci sorgenti Tile personalizzati
- Aggiungi sorgente Tile Personalizzato
- Nessuna Sorgente Personalizzata dei Tile
- Modifica sorgenti Tile personalizzati
- Elimina sorgenti Tile personalizzati
Il nome non può essere vuoto.
Il nome del provider esiste.
L'URL non può essere vuoto.
@@ -947,4 +941,5 @@
Tutti
Bluetooth
Configurazione
+
diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
index 165d54b2f..109f5e579 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -559,4 +559,5 @@
すべて
Bluetooth
+
diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
index fc578018e..ebb0190d4 100644
--- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
@@ -568,4 +568,5 @@
All
블루투스
설정
+
diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
index 45e30e386..0ed59d06a 100644
--- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
@@ -247,4 +247,5 @@
Nenustatyta
+
diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml
index 2e5e6e16b..9f9cd310c 100644
--- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml
@@ -436,4 +436,5 @@
Alles
Bluetooth
+
diff --git a/core/resources/src/commonMain/composeResources/values-no/strings.xml b/core/resources/src/commonMain/composeResources/values-no/strings.xml
index ca2cafa0e..b0a0ba9d6 100644
--- a/core/resources/src/commonMain/composeResources/values-no/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-no/strings.xml
@@ -250,4 +250,5 @@
Lås opp
+
diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
index c57703502..1c617170d 100644
--- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
@@ -811,4 +811,5 @@
Wszystkie
Bluetooth
Konfiguracja
+
diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
index fe90da9a6..8efbac0df 100644
--- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
@@ -680,7 +680,6 @@
Híbrido
Gerenciar Camadas do Mapa
Camadas do Mapa
- Nenhuma camada personalizada carregada.
Adicionar Camada
Ocultar Camada
Mostrar Camada
@@ -689,10 +688,6 @@
Nós neste local
Tipo de Mapa Selecionado
Gerenciar Fontes de Bloco Personalizados
- Gerenciar Fontes de Blocos Personalizados
- Sem Fontes de Blocos Personalizadas
- Editar Fonte do Bloco Personalizado
- Excluir Fonte do Bloco Personalizado
Nome não pode estar vazio.
O nome do provedor existe.
A URL não pode estar vazia.
@@ -710,4 +705,5 @@
Bluetooth
+
diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
index 1144ac7d1..5bfc89042 100644
--- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
@@ -554,4 +554,5 @@
Tudo
Bluetooth
Configuração
+
diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
index 01657ac4d..79dafabb3 100644
--- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
@@ -639,4 +639,5 @@
Toate
Bluetooth
+
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index b834de90c..af5c367d7 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -940,9 +940,7 @@
Ландшафт
Смешанный
Управление Слоями Карты
- Пользовательские слои поддерживают файлы .kml, .kmz или GeoJSON.
Слои карты
- Пользовательские слои не загружены.
Добавить слой
Скрыть слой
Показать слой
@@ -951,10 +949,6 @@
Ноды в этом месте
Выбранный тип карты
Управление собственными источниками плиток
- Добавить свой источник плиток
- Нет пользовательских источников плиток
- Изменить свой источник плиток
- Удалить свой источник плиток
Имя не может быть пустым.
Имя провайдера уже существует.
URL не может быть пустым.
@@ -1175,4 +1169,5 @@
Статистика Meshtastic
Обновить
Обновлено
+
diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
index 299bb1b21..2e7ec0cda 100644
--- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
@@ -459,4 +459,5 @@
Všetky
Bluetooth
+
diff --git a/core/resources/src/commonMain/composeResources/values-sl/strings.xml b/core/resources/src/commonMain/composeResources/values-sl/strings.xml
index b238e6898..36dc79a93 100644
--- a/core/resources/src/commonMain/composeResources/values-sl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sl/strings.xml
@@ -254,4 +254,5 @@
Ni nastavljeno
+
diff --git a/core/resources/src/commonMain/composeResources/values-sq/strings.xml b/core/resources/src/commonMain/composeResources/values-sq/strings.xml
index fd8a14544..dadfe99d6 100644
--- a/core/resources/src/commonMain/composeResources/values-sq/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sq/strings.xml
@@ -226,4 +226,5 @@
I pa konfiguruar
+
diff --git a/core/resources/src/commonMain/composeResources/values-sr/strings.xml b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
index f8a2e38a9..5dff2f3b5 100644
--- a/core/resources/src/commonMain/composeResources/values-sr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sr/strings.xml
@@ -455,4 +455,5 @@
Сви
Блутут
Напајано
+
diff --git a/core/resources/src/commonMain/composeResources/values-srp/strings.xml b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
index e35037d52..53d116308 100644
--- a/core/resources/src/commonMain/composeResources/values-srp/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-srp/strings.xml
@@ -455,4 +455,5 @@
Сви
Блутут
Напајано
+
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index 92d8b3e87..54bb8ec09 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -887,9 +887,7 @@
Terräng
Hybrid
Hantera kartlager
- Anpassade lager stödjer .kml, .kmz, eller GeoJSON-filer.
Kartlager
- Inga anpassade lager inlästa.
Lägg till lager
Dölj lager
Visa lager
@@ -898,10 +896,6 @@
Noder på denna plats
Vald karttyp
Hantera kartkällor
- Lägg till kartkälla
- Inga kartkällor
- Redigera kartkälla
- Ta bort kartkälla
Namn kan inte vara tomt.
Leverantörens namn finns redan.
URL kan inte vara odefinierad.
@@ -1036,4 +1030,5 @@
Alla
Bluetooth
Konfiguration
+
diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
index f7bf2c4ac..9bad5ac60 100644
--- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
@@ -575,4 +575,5 @@
Hepsi
Bluetooth
Yapılandırma
+
diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
index 6b38ad9d5..486ed99ab 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -794,4 +794,5 @@
Усі
Bluetooth
Налаштування
+
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index d89f6a908..d008c928f 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -930,9 +930,7 @@
地形
混合
管理地图图层
- 自定义图层支持 .kml, .kmz, 或 GeoJSON 文件。
地图图层
- 没有自定义图层被加载。
添加图层
隐藏图层
显示图层
@@ -941,10 +939,6 @@
在此位置的节点
所选地图类型
管理自定义瓦片源
- 添加自定义瓦片源
- 没有自定义瓦片源
- 编辑自定义瓦片源
- 删除自定义瓦片源
名称不能为空。
服务提供商名已存在。
URL 不能为空。
@@ -1159,4 +1153,5 @@
Meshtastic 统计
刷新
更新
+
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index 484a72595..97b7a5632 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -929,9 +929,7 @@
地形
混合
管理地圖圖層
- 自訂圖層支援 .kml、.kmz 或 GeoJSON 檔案。
地圖圖層
- 未載入自訂圖層。
添加圖層
隱藏圖層
顯示圖層
@@ -940,10 +938,6 @@
位於此處的節點
已選擇的地圖類型
管理自定義圖磚來源
- 加入自定義圖磚來源
- 沒有自定義圖專來源
- 編輯自定義圖磚來源
- 刪除自定義圖磚來源
名稱不得空白。
服務供應商名稱已存在。
URL 不得空白。
@@ -1152,4 +1146,5 @@
Meshtastic 統計
重新整理
已更新
+
From 3b531b61a3563dcfd0a009708b69e09646570bd4 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 09:48:10 -0600
Subject: [PATCH 032/474] ci: Split Google artifact attestations and ensure
F-Droid uploads (#4665)
---
.github/workflows/release.yml | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2db494892..b745d0850 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -195,13 +195,17 @@ jobs:
path: app/build/outputs/apk/**/*.apk
retention-days: 1
- - name: Attest Google artifacts provenance
+ - name: Attest Google AAB provenance
if: always()
uses: actions/attest-build-provenance@v4
with:
- subject-path: |
- app/build/outputs/bundle/googleRelease/app-google-release.aab
- app/build/outputs/apk/**/*.apk
+ subject-path: app/build/outputs/bundle/googleRelease/app-google-release.aab
+
+ - name: Attest Google APK provenance
+ if: always()
+ uses: actions/attest-build-provenance@v4
+ with:
+ subject-path: app/build/outputs/apk/**/*.apk
release-fdroid:
runs-on: ubuntu-latest
@@ -257,6 +261,7 @@ jobs:
run: ls -R app/build/outputs/
- name: Upload F-Droid APK artifact
+ if: always()
uses: actions/upload-artifact@v7
with:
name: fdroid-apk
@@ -264,6 +269,7 @@ jobs:
retention-days: 1
- name: Attest F-Droid APK provenance
+ if: always()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/apk/**/*.apk
From 5da54fc001a162d3558dd8bf11149d57514bca0e Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 10:14:36 -0600
Subject: [PATCH 033/474] Merge pull request #4666
* Replace strings.xml with app_name resource
---
app/src/main/res/values/strings.xml | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
create mode 100644 app/src/main/res/values/strings.xml
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..ba56e1790
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,20 @@
+
+
+
+ Meshtastic
+
From 8347f2e56e53613f3aaf53d4942d039622877dac Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 27 Feb 2026 16:45:12 +0000
Subject: [PATCH 034/474] chore(release): prepare v2.7.14-internal.2 [skip ci]
- Bump base version to 2.7.14
- Sync translations and assets
---
.../composeResources/values-de/strings.xml | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index c64989258..e7c820b54 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -714,6 +714,7 @@
Langer Name
Kurzname
Geräte-Modell
+ Lizenzierter Amateurfunker
Das Aktivieren dieser Option deaktiviert die Verschlüsselung und ist nicht mit dem Standardnetzwerk von Meshtastic kompatibel.
Taupunkt
Druck
@@ -932,7 +933,9 @@
Gelände
Hybrid
Kartenebenen verwalten
+ Kartenebenen unterstützen kml, kmz oder GeoJSON Format.
Kartenebenen
+ Keine Kartenebenen geladen.
Ebene hinzufügen
Ebene ausblenden
Ebene anzeigen
@@ -941,6 +944,10 @@
Knoten an diesem Standort
Ausgewählter Kartentyp
Benutzerdefinierte Kachelquellen verwalten
+ Netzwerk Kachelquelle hinzufügen
+ Keine eigenen Kachelquellen gefunden.
+ Netzwerk Kachelquelle bearbeiten
+ Netzwerk Kachelquelle löschen
Name darf nicht leer sein.
Der Name des Anbieters existiert bereits.
URL darf nicht leer sein.
@@ -1140,11 +1147,25 @@
Knoten: %1$d online / %2$d gesamt
Laufzeit: %1$s
Kanalauslastung: %1$.2f%% | Sendezeit: %2$.2f%%
+ Datenverkehr: TX %1$d / RX %2$d (Duplikate: %3$d)
Weiterleitungen: %1$d (Abgebrochen: %2$d)
Diagnose %1$s
Rauschen %1$d dBm
Fehlerhaft %1$d
Verworfen %1$d
+ Heap
+ %1$d / %2$d
+ %1$s
Angeschaltet
+ Meshtastic Statistiken
+ Aktualisieren
+ Aktualisiert
+ Netzwerkebene hinzufügen
+ Ebene aktualisieren
+ Lokale MB Kacheldatei
+ Lokale MB Kacheldatei hinzufügen
+ Ungültiger Name, URL oder lokale URI für benutzerdefinierten Kachelanbieter.
+ Ein benutzerdefinierter Kachelanbieter mit diesem Namen existiert bereits.
+ Fehler beim Kopieren der MB Kacheldatei in den internen Speicher.
From 145c256972df4385bdde6161c0e8727fedf82adf Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:22:36 -0600
Subject: [PATCH 035/474] Merge pull request #4668
* Disable generate_release_notes in release workflow
---
.github/workflows/release.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b745d0850..0cdde8668 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -306,7 +306,7 @@ jobs:
token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }}
tag_name: ${{ inputs.tag_name }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
- generate_release_notes: true
+ generate_release_notes: false
files: ./artifacts/*/*
draft: false
prerelease: true
From 225dc232b67d8561308e2f818a4645230f38afc5 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:25:41 -0600
Subject: [PATCH 036/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4667)
---
.../values-zh-rTW/strings.xml | 136 ++++++++++--------
1 file changed, 75 insertions(+), 61 deletions(-)
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index 97b7a5632..17a1812e8 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -97,7 +97,7 @@
僅限本地
忽略來自開放的或無法解密的外部 Mesh 觀察到的訊息。僅轉播來自本地節點的主要/次要頻道的訊息。
僅限已知節點
- 近似於 LOCAL_ONLY 角色,將忽略來自外部Mesh節點的訊息,同時也忽略已知節點列表以外節點的訊息。
+ 近似於 LOCAL_ONLY 角色,將忽略來自外部 Mesh 節點的訊息,同時也忽略已知節點列表以外節點的訊息。
無
僅允許 SENSOR、TRACKER 和 TAK_TRACKER 角色,與 CLIENT_MUTE 角色不同,此模式將禁止所有重新廣播行為。
僅轉發基本通訊封包
@@ -121,18 +121,18 @@
可選的預設參數組,預設值是 Long Fast。
設定訊息的最大跳數,預設為 3。注意:增加跳數將導致網路擁塞,建議謹慎使用。此外,0 跳的廣播訊息將不會收到確認 (ACK)。
節點工作頻率是透過地區、預設參數組和此欄位計算的。當設為 0 時,時隙將根據主頻道名稱自動計算,並會與公共預設時隙不同。若同時配置了私人主頻道和公共副頻道,請務必切換回公共預設時隙。
- Very Long Range - Slow
- Long Range - Fast
- 長距離 - 快速模式
- Long Range - Moderate
- Long Range - Slow
- Medium Range - Fast
- Medium Range - Slow
- Short Range - Turbo
- Short Range - Fast
- Short Range - Slow
- 啟用 Wi-Fi 後,藍牙與應用程式的連線將會停用。
- 啟用乙太網路會導致與 App 的藍牙連線中斷。此外,TCP 節點連線在 Apple 設備上不可用。
+ Very Long - Slow
+ Long - Fast
+ Long - Turbo
+ Long - Moderate
+ Long - Slow
+ Medium - Fast
+ Medium - Slow
+ Short - Turbo
+ Short - Fast
+ Short - Slow
+ 啟用 Wi-Fi 後,節點裝置的藍牙連線功能將會停用。
+ 啟用乙太網路後,節點裝置的藍牙連線功能將會停用。此外,TCP 節點連線在 Apple 設備上無法使用。
允許透過本地網路上的 UDP 廣播封包。
位置廣播的最大間隔時間。
滿足最小距離限制時,位置更新的最快發送間隔。
@@ -161,7 +161,7 @@
GPS 傳送腳位
GPS 啟用腳位
腳位
- 调试
+ 除錯
頻道
頻道名稱
QRCODE
@@ -178,7 +178,7 @@
Meshtastic需要啟用定位及藍芽才能尋找新裝置,可以選擇在不使用時停用。
回報BUG
回報問題
- 您確定要報告錯誤嗎?報告後,請在 https://github.com/orgs/meshtastic/discussions 上貼文,以便我們可以將報告與您發現的問題匹配。
+ 您確定要報告錯誤嗎?報告後,請在 https://github.com/orgs/meshtastic/discussions 上貼文,以便我們可以將報告與您發現的問題比對。
報告
配對完成,開始服務
配對失敗,請重新選擇
@@ -189,7 +189,7 @@
設備休眠中
已連接:線上 %1$s
IP地址:
- Ip_ 埠:
+ IP連接埠:
已連線
已連接至設備 (%1$s)
目前連線:
@@ -201,7 +201,7 @@
已連接裝置,但該裝置正在休眠中
需要應用程式更新
您必須在應用商店(或 Github)更新此應用程式。它太舊無法與此無線電韌體通訊。請閱讀我們關於此主題的文件。
- 無(停用)
+ 無(停用)
服務通知
致謝
此頻道 URL 無效,無法使用
@@ -263,7 +263,7 @@
系統預設
選擇主題
將手機位置提供給Mesh網路
- 西里爾字母緊湊編碼
+ 使用同形異意字元編碼處理西里爾字母
- 刪除 %1$s 訊息?
@@ -286,12 +286,12 @@
此裝置不支援關機功能
⚠️ 這將會關閉節點。需要實體操作才能重新開啟。
⚠️ 這是關鍵基礎設施節點。請輸入節點名稱以確認:
- 裝置: %1$s
- 請輸入: %1$s
+ 裝置:%1$s
+ 請輸入:%1$s
重新開機
路由追蹤
顯示介紹指南
- 訊息:
+ 訊息:
快速聊天選項
新的快速聊天
編輯快速聊天
@@ -303,7 +303,7 @@
恢復出廠設置
藍芽已關閉,請至手機設定內開啟藍芽功能。
開啟設定
- 韌體版本: %1$s
+ 韌體版本:%1$s
Meshtastic 應用程式需要啟用「鄰近裝置」權限,才能透過藍牙尋找並連接到裝置,可以選擇在不使用時停用。
直通訊息
重設節點資料庫
@@ -314,7 +314,7 @@
將 '%1$s' 加入忽略清單嗎?
從忽略清單中移除 '%1$s' 嗎?
選擇下載地區
- 圖磚下載估計:
+ 圖磚下載估計:
開始下載
交換位置
關閉
@@ -329,14 +329,14 @@
清除下載的圖磚
圖磚來源
清除 %1$s 的 SQL 快取
- SQL快取清除失敗,請查看logcat以獲取詳細資訊。
+ SQL快取清除失敗,請查看 logcat 以獲取詳細資訊。
快取管理
下載已完成!
下載完成,但有 %1$d 個錯誤
%1$d 圖磚
方位:%1$d° 距離:%2$s
編輯航點
- 刪除航點?
+ 刪除航點?
新建航點
收到編輯航點:%1$s
達到循環工作週期限制。目前無法發送訊息,請稍後再試。
@@ -356,8 +356,8 @@
將「%1$s」的通知設為靜音?
取消「%1$s」的通知靜音?
替換
- 掃描WiFi QR code
- 錯誤的 WiFi 驗證QR code格式
+ 掃描Wi-Fi QR code
+ 錯誤的 Wi-Fi 驗證QR code格式
返回上一頁
電池
頻道利用率
@@ -414,10 +414,10 @@
在地圖上檢視
此路由追蹤尚未包含任何可標記於地圖的節點。
顯示 %1$d / %2$d 個節點
- 持續時間: %1$s 秒
+ 持續時間:%1$s 秒
%1$s - %2$s
- 追蹤至目的地的路由: \n\n
- 追蹤回到本機的路由: \n\n
+ 追蹤至目的地的路由:\n\n
+ 追蹤回到本機的路由:\n\n
1小時
二十四小時
四十八小時
@@ -446,16 +446,16 @@
我知道我在做什麼。
節點 %1$s 電量過低 (%2$d%%)
低電量通知
- 低電量:%1$s
+ 低電量:%1$s
低電量通知(收藏節點)
氣壓
已啟用
UDP 廣播
- UDP設置
+ UDP 設置
最後接收: %2$s
最後位置: %3$s
電量: %4$s]]>
切換我的位置
- 以北為上
- 用戶
+ 定位朝北
+ 使用者
頻道
裝置
位置
@@ -463,8 +463,8 @@
網路
顯示
LoRa
- 藍芽
- 安全
+ 藍牙
+ 安全性
MQTT
序列埠
外部通知
@@ -541,7 +541,7 @@
轉發模式
節點資訊廣播間隔
雙擊觸發按鈕功能
- 三擊執行臨時 Ping
+ 三擊執行 Ad Hoc Ping
時區
LED 心跳指示
裝置列表
@@ -616,7 +616,7 @@
網路
Wi-Fi 選項
已啟用
- 啟用WiFi
+ 啟用Wi-Fi
SSID
PSK
取得文件
@@ -628,12 +628,12 @@
IP
網閘
子網路
- Paxcount設置
- 啟用Paxcount
+ 人流計數(Paxcount)設置
+ 已啟用人流計數(Paxcount)
狀態訊息
狀態訊息設定
實際狀態字串
- WiFi RSSI 閾值(預設為-80)
+ Wi-Fi RSSI 閾值(預設為-80)
藍牙 RSSI 閾值(預設為-80)
位置
位置廣播間隔(秒)
@@ -678,7 +678,7 @@
管理員金鑰
託管模式
序列控制台
- 啟用調適日誌 API
+ 啟用除錯日誌 API
舊版管理頻道
序列埠設定
啟用序列埠
@@ -731,10 +731,10 @@
節點編號
使用者 ID
運行時間
- 負載:%1$d
+ 負載:%1$d
正在取得頻道 %1$d / %2$d
正在取得 %1$s
- 硬碟可用空間:%1$d
+ 硬碟可用空間:%1$d
時間戳記
航向
速度
@@ -847,7 +847,7 @@
未加密頻道,精確定位
紅色開鎖表示該頻道未進行安全加密,啟用了精確定位資訊,且未使用任何金鑰或使用 1 位元組已知金鑰。
- 警告:未加密頻道,精確定位 & MQTT Uplink
+ 警告:未加密頻道,已啟用精確定位 & MQTT Uplink
帶有警告的紅色開鎖表示該頻道未進行安全加密,啟用了精確定位資訊,且正在透過MQTT上傳資料至網路,以及未使用任何金鑰或使用 1 位元組已知金鑰。
頻道安全性
@@ -867,7 +867,7 @@
PAX 人流計量
PAX
無可用的 PAX 人流計量資料。
- WiFi 裝置
+ Wi-Fi 裝置
藍牙裝置
已配對的裝置
連接裝置
@@ -929,7 +929,9 @@
地形
混合
管理地圖圖層
+ 自訂圖層支援 .kml、.kmz 或 GeoJSON 檔案。
地圖圖層
+ 未載入自訂圖層。
添加圖層
隱藏圖層
顯示圖層
@@ -938,6 +940,10 @@
位於此處的節點
已選擇的地圖類型
管理自定義圖磚來源
+ 加入自定義圖磚來源
+ 沒有自定義圖專來源
+ 編輯自定義圖磚來源
+ 刪除自定義圖磚來源
名稱不得空白。
服務供應商名稱已存在。
URL 不得空白。
@@ -968,8 +974,8 @@
系統設定
沒有可用的統計資料
我們會收集分析數據以協助改善 Android 應用程式(感謝您的支持),我們將收到匿名化的使用者行為資訊,包括當機報告、應用程式使用畫面等。
- 分析平台:
- 欲了解更多資訊,請查閱我們的隱私權政策。
+ 分析平台:
+ 如欲了解更多資訊,請查閱我們的隱私權政策。
預設值 - 0
經由:%1$s
@@ -977,8 +983,9 @@
%1$s 裝置出廠時預載的開機載入程式通常不支援 OTA 更新功能。在執行 OTA 韌體更新前,您可能需要先透過 USB 連線刷入具備 OTA 功能的開機載入程式。
瞭解詳情
- 針對 RAK WisBlock RAK4631 裝置,必須使用 ' 提供的序列埠 DFU(裝置韌體更新)工具進行更新。舉例來說,可以使用 adafruit-nrfutil dfu serial 命令配合提供的 bootloader .zip 壓縮檔。注意:單純複製 .uf2 檔案並不會更新開機載入程式。
- ' 此裝置不再顯示
+ 針對 RAK WisBlock RAK4631 裝置,必須使用原廠提供的序列埠 DFU(裝置韌體更新)工具進行更新。舉例來說,可以搭配使用 adafruit-nrfutil dfu serial 隨附的 bootloader.zip 壓縮檔。注意:單純複製 .uf2 檔案並不會更新開機載入程式(Bootloader)。
+
+ 不再顯示此裝置的提示
保留我的最愛?
USB 裝置
@@ -1014,27 +1021,27 @@
處理中,請稍候⋯⋯
選擇本機檔案
本機檔案
- 來源: 本機檔案
+ 來源:本機檔案
無法識別的遠端版本
更新警告
- 您即將為裝置刷入新韌體,此過程存在風險。\n\n 請確保裝置電量充足。\n 請將裝置保持在手機附近。\n 更新期間請勿關閉應用程式。\n\n 請確認您已為您的硬體選擇正確的韌體。
- Chirpy 小提醒:「別忘了準備梯子!」
+ 您即將為裝置刷入新韌體,此過程存在風險。\n\n• 請確保裝置電量充足。\n• 請將裝置保持在手機附近。\n• 更新期間請勿關閉應用程式。\n\n請確認您已為您的硬體選擇正確的韌體。
+ Chirpy 小提醒:「緊握扶手!」
Chirpy
正在進入 DFU 模式⋯⋯
等待裝置進入 DFU 模式⋯⋯
正在複製韌體⋯⋯記得要強調是史上最快喔!
- 請將 .uf2 檔案儲存到您 ' 裝置 DFU 磁碟機。
+ 請將 .uf2 檔案複製到您裝置 DFU 的磁碟機。
刷入韌體中,請稍等⋯⋯
USB 檔案傳輸
BLE OTA
- WiFi OTA
+ Wi-Fi OTA
更新方式 %1$s
選擇 DFU USB 磁碟機
- 您的裝置已重新啟動進入 DFU 模式,應該會顯示為 USB 磁碟機(例如:RAK4631)。\n\n 當檔案選擇器開啟時,請選擇該磁碟機的根目錄以儲存韌體檔案。
+ 您的裝置已重新啟動進入 DFU 模式,應該會顯示為 USB 磁碟機(例如:RAK4631)。\n\n當檔案管理器開啟時,請選擇該磁碟機的根目錄以儲存韌體檔案。
正在驗證更新⋯⋯
驗證逾時。裝置未能在時限內重新連線。
等待裝置重新連線⋯⋯
- 目標裝置: %1$s
+ 目標裝置:%1$s
版本說明
未知錯誤
本機更新失敗
@@ -1047,9 +1054,9 @@
USB 更新失敗
韌體雜湊值遭拒。裝置可能需要雜湊值配置或開機載入程式更新。
OTA 更新失敗: %1$s
- Loading firmware⋯⋯
+ 正在載入韌體⋯⋯
等待裝置重新啟動至 OTA 模式⋯⋯
- 正在連線至裝置(第 %1$d / %2$d次嘗試 )⋯⋯
+ 正在連線至裝置(第 %1$d / %2$d次嘗試)⋯⋯
正在檢查裝置版本⋯⋯
正在啟動 OTA 更新⋯⋯
正在上傳韌體⋯⋯
@@ -1073,8 +1080,8 @@
指南針
開啟指南針
- 距離: %1$s
- 方位: %1$s
+ 距離:%1$s
+ 方位:%1$s
方位:無資料
此裝置沒有指南針感測器,無法取得方向資訊。
需要位置權限才能顯示距離和方位。
@@ -1147,4 +1154,11 @@
重新整理
已更新
+ 新增線上圖層
+ 重新整理圖層
+ 本機 MBTiles 檔案
+ 新增本機 MBTiles 檔案
+ 自訂圖磚來源的名稱、URL 範本或本機 URI 無效。
+ 已存在相同名稱的自訂圖磚來源。
+ 無法將 MBTiles 檔案複製至內部儲存空間。
From a07992530c964de430c2c3793b12f77a44f63ec8 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:33:43 -0600
Subject: [PATCH 037/474] feat: Improve edge-to-edge and display cutout
handling (#4669)
---
.../java/com/geeksville/mesh/MainActivity.kt | 29 +++++++++++++------
.../main/java/com/geeksville/mesh/ui/Main.kt | 4 +--
2 files changed, 21 insertions(+), 12 deletions(-)
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index 3b5dffc1e..0fbe657ce 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -26,6 +26,7 @@ import android.nfc.NdefMessage
import android.nfc.NfcAdapter
import android.os.Build
import android.os.Bundle
+import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.ReportDrawnWhen
@@ -75,6 +76,19 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ // Explicitly set the cutout mode to ALWAYS for Android 15+ to satisfy Play Console recommendations.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+ window.attributes.layoutInDisplayCutoutMode =
+ WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
+ }
+
+ // Ensure the navigation bar remains seamless on modern Android versions
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ window.isNavigationBarContrastEnforced = false
+ }
+
setContent {
val theme by model.theme.collectAsStateWithLifecycle()
val dynamic = theme == MODE_DYNAMIC
@@ -85,15 +99,12 @@ class MainActivity : ComponentActivity() {
else -> isSystemInDarkTheme()
}
- // Apply modern edge-to-edge drawing with theme-aware system bars
- enableEdgeToEdge(
- statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
- navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
- )
-
- // Ensure the navigation bar remains seamless on modern Android versions
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- window.isNavigationBarContrastEnforced = false
+ // Update system bar style when theme changes
+ androidx.compose.runtime.SideEffect {
+ enableEdgeToEdge(
+ statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
+ navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
+ )
}
@Suppress("SpreadOperator")
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 c4f9d3fb5..8a31155eb 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -31,8 +31,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.recalculateWindowInsets
-import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -448,7 +446,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: ScannerVie
NavHost(
navController = navController,
startDestination = NodesRoutes.NodesGraph,
- modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
+ modifier = Modifier.fillMaxSize(),
) {
contactsGraph(navController, uIViewModel.scrollToTopEventFlow)
nodesGraph(navController, uIViewModel.scrollToTopEventFlow)
From b2b21e10e26b2dae4e80570ea4ae32cb83404647 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 27 Feb 2026 11:44:19 -0600
Subject: [PATCH 038/474] feat: upcoming support for tak and trafficmanagement
configs, device hw (#4671)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/src/main/assets/device_hardware.json | 42 +++
.../mesh/navigation/SettingsNavigation.kt | 7 +
core/common/build.gradle.kts | 5 +-
.../org/meshtastic/core/database/model/TAK.kt | 96 +++++
core/model/build.gradle.kts | 23 ++
.../org/meshtastic/core/model/ChannelTest.kt | 0
.../core/model/util/ChannelSetTest.kt | 0
.../core/model/util/SharedContactTest.kt | 0
.../meshtastic/core/model/CapabilitiesTest.kt | 92 ++---
.../core/model/ChannelOptionTest.kt | 0
.../core/model/DataPacketParcelTest.kt | 38 +-
.../meshtastic/core/model/DataPacketTest.kt | 3 +-
.../core/model/DeviceVersionTest.kt | 0
.../org/meshtastic/core/model/NodeInfoTest.kt | 16 +-
.../org/meshtastic/core/model/PositionTest.kt | 13 +-
.../core/model/util/SharedContactTest.kt | 10 +-
.../core/model/util/UriUtilsTest.kt | 16 +-
.../core/model/util/ExtensionsTest.kt | 100 ------
.../core/model/util/SfppHasherTest.kt | 95 -----
.../core/model/util/TimeExtensionsTest.kt | 103 ------
.../core/model/util/UnitConversionsTest.kt | 118 ------
.../core/model/util/WireExtensionsTest.kt | 336 ------------------
.../org/meshtastic/core/model/Capabilities.kt | 56 +--
.../meshtastic/core/model/DeviceVersion.kt | 18 +-
.../org/meshtastic/core/navigation/Routes.kt | 4 +
core/resources/build.gradle.kts | 5 +-
.../composeResources/values/strings.xml | 48 +++
.../core/ui/component/DropDownPreference.kt | 36 +-
.../settings/navigation/ModuleRoute.kt | 43 ++-
.../feature/settings/radio/RadioConfig.kt | 10 +-
.../settings/radio/RadioConfigViewModel.kt | 7 +
.../radio/component/TAKConfigItemList.kt | 80 +++++
.../TrafficManagementConfigItemList.kt | 208 +++++++++++
33 files changed, 737 insertions(+), 891 deletions(-)
create mode 100644 core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt
rename core/model/src/{androidTest => androidDeviceTest}/kotlin/org/meshtastic/core/model/ChannelTest.kt (100%)
rename core/model/src/{androidTest => androidDeviceTest}/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt (100%)
rename core/model/src/{androidTest => androidDeviceTest}/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt (100%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt (56%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt (100%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt (76%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/DataPacketTest.kt (98%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt (100%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/NodeInfoTest.kt (73%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/PositionTest.kt (72%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt (92%)
rename core/model/src/{androidUnitTest => androidHostTest}/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt (97%)
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
delete mode 100644 core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt
diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json
index 0699ff16b..71143aa72 100644
--- a/app/src/main/assets/device_hardware.json
+++ b/app/src/main/assets/device_hardware.json
@@ -1349,5 +1349,47 @@
"images": [
"tbeam-1w.svg"
]
+ },
+ {
+ "hwModel": 123,
+ "hwModelSlug": "T5_S3_EPAPER_PRO",
+ "platformioTarget": "t5-s3-epaper-pro",
+ "architecture": "esp32-s3",
+ "activelySupported": true,
+ "supportLevel": 1,
+ "displayName": "LilyGo T5 S3 ePaper Pro",
+ "tags": [
+ "LilyGo"
+ ],
+ "hasMui": true,
+ "partitionScheme": "8MB"
+ },
+ {
+ "hwModel": 124,
+ "hwModelSlug": "TBEAM_BPF",
+ "platformioTarget": "tbeam-bpf",
+ "architecture": "esp32-s3",
+ "activelySupported": true,
+ "supportLevel": 1,
+ "displayName": "LilyGo T-Beam BPF",
+ "tags": [
+ "LilyGo"
+ ],
+ "hasMui": false,
+ "partitionScheme": "8MB"
+ },
+ {
+ "hwModel": 125,
+ "hwModelSlug": "MINI_EPAPER_S3",
+ "platformioTarget": "mini-epaper-s3",
+ "architecture": "esp32-s3",
+ "activelySupported": true,
+ "supportLevel": 1,
+ "displayName": "LilyGo T-Mini E-paper S3 Kit",
+ "tags": [
+ "LilyGo"
+ ],
+ "hasMui": true,
+ "partitionScheme": "8MB"
}
]
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
index aa498f009..18522c531 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
@@ -61,7 +61,9 @@ import org.meshtastic.feature.settings.radio.component.SecurityConfigScreen
import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
+import org.meshtastic.feature.settings.radio.component.TAKConfigScreen
import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen
+import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigScreen
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
import kotlin.reflect.KClass
@@ -167,6 +169,11 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
ModuleRoute.STATUS_MESSAGE ->
StatusMessageConfigScreen(viewModel, onBack = navController::popBackStack)
+
+ ModuleRoute.TRAFFIC_MANAGEMENT ->
+ TrafficManagementConfigScreen(viewModel, onBack = navController::popBackStack)
+
+ ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = navController::popBackStack)
}
}
}
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 8f55e26fc..41a0c8a3d 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -22,7 +22,10 @@ plugins {
kotlin {
@Suppress("UnstableApiUsage")
- android { androidResources.enable = false }
+ android {
+ androidResources.enable = false
+ withHostTest { isIncludeAndroidResources = true }
+ }
sourceSets {
commonMain.dependencies {
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt
new file mode 100644
index 000000000..bf5cddffc
--- /dev/null
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/TAK.kt
@@ -0,0 +1,96 @@
+/*
+ * 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.database.model
+
+import org.jetbrains.compose.resources.StringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.tak_role_forwardobserver
+import org.meshtastic.core.resources.tak_role_hq
+import org.meshtastic.core.resources.tak_role_k9
+import org.meshtastic.core.resources.tak_role_medic
+import org.meshtastic.core.resources.tak_role_rto
+import org.meshtastic.core.resources.tak_role_sniper
+import org.meshtastic.core.resources.tak_role_teamlead
+import org.meshtastic.core.resources.tak_role_teammember
+import org.meshtastic.core.resources.tak_role_unspecified
+import org.meshtastic.core.resources.tak_team_blue
+import org.meshtastic.core.resources.tak_team_brown
+import org.meshtastic.core.resources.tak_team_cyan
+import org.meshtastic.core.resources.tak_team_dark_blue
+import org.meshtastic.core.resources.tak_team_dark_green
+import org.meshtastic.core.resources.tak_team_green
+import org.meshtastic.core.resources.tak_team_magenta
+import org.meshtastic.core.resources.tak_team_maroon
+import org.meshtastic.core.resources.tak_team_orange
+import org.meshtastic.core.resources.tak_team_purple
+import org.meshtastic.core.resources.tak_team_red
+import org.meshtastic.core.resources.tak_team_teal
+import org.meshtastic.core.resources.tak_team_unspecified_color
+import org.meshtastic.core.resources.tak_team_white
+import org.meshtastic.core.resources.tak_team_yellow
+import org.meshtastic.proto.MemberRole
+import org.meshtastic.proto.Team
+
+@Suppress("CyclomaticComplexMethod")
+fun getStringResFrom(team: Team): StringResource = when (team) {
+ Team.Unspecifed_Color -> Res.string.tak_team_unspecified_color
+ Team.White -> Res.string.tak_team_white
+ Team.Yellow -> Res.string.tak_team_yellow
+ Team.Orange -> Res.string.tak_team_orange
+ Team.Magenta -> Res.string.tak_team_magenta
+ Team.Red -> Res.string.tak_team_red
+ Team.Maroon -> Res.string.tak_team_maroon
+ Team.Purple -> Res.string.tak_team_purple
+ Team.Dark_Blue -> Res.string.tak_team_dark_blue
+ Team.Blue -> Res.string.tak_team_blue
+ Team.Cyan -> Res.string.tak_team_cyan
+ Team.Teal -> Res.string.tak_team_teal
+ Team.Green -> Res.string.tak_team_green
+ Team.Dark_Green -> Res.string.tak_team_dark_green
+ Team.Brown -> Res.string.tak_team_brown
+}
+
+fun getStringResFrom(role: MemberRole): StringResource = when (role) {
+ MemberRole.Unspecifed -> Res.string.tak_role_unspecified
+ MemberRole.TeamMember -> Res.string.tak_role_teammember
+ MemberRole.TeamLead -> Res.string.tak_role_teamlead
+ MemberRole.HQ -> Res.string.tak_role_hq
+ MemberRole.Sniper -> Res.string.tak_role_sniper
+ MemberRole.Medic -> Res.string.tak_role_medic
+ MemberRole.ForwardObserver -> Res.string.tak_role_forwardobserver
+ MemberRole.RTO -> Res.string.tak_role_rto
+ MemberRole.K9 -> Res.string.tak_role_k9
+}
+
+@Suppress("CyclomaticComplexMethod", "MagicNumber")
+fun getColorFrom(team: Team): Long = when (team) {
+ Team.Unspecifed_Color -> 0xFF00FFFF // Default to Cyan
+ Team.White -> 0xFFFFFFFF
+ Team.Yellow -> 0xFFFFFF00
+ Team.Orange -> 0xFFFFA500
+ Team.Magenta -> 0xFFFF00FF
+ Team.Red -> 0xFFFF0000
+ Team.Maroon -> 0xFF800000
+ Team.Purple -> 0xFF800080
+ Team.Dark_Blue -> 0xFF00008B
+ Team.Blue -> 0xFF0000FF
+ Team.Cyan -> 0xFF00FFFF
+ Team.Teal -> 0xFF008080
+ Team.Green -> 0xFF00FF00
+ Team.Dark_Green -> 0xFF006400
+ Team.Brown -> 0xFFA52A2A
+}
diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts
index 902098124..951403976 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -25,6 +25,13 @@ plugins {
apply(from = rootProject.file("gradle/publishing.gradle.kts"))
kotlin {
+ @Suppress("UnstableApiUsage")
+ android {
+ androidResources.enable = false
+ withHostTest { isIncludeAndroidResources = true }
+ withDeviceTest { instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
+ }
+
sourceSets {
commonMain.dependencies {
api(projects.core.proto)
@@ -37,9 +44,25 @@ kotlin {
}
androidMain.dependencies {
api(libs.androidx.annotation)
+ api(libs.androidx.core.ktx)
implementation(libs.zxing.core)
}
commonTest.dependencies { implementation(kotlin("test")) }
+ val androidHostTest by getting {
+ dependencies {
+ implementation(libs.junit)
+ implementation(libs.robolectric)
+ implementation(libs.mockk)
+ implementation(libs.androidx.test.ext.junit)
+ implementation(kotlin("test"))
+ }
+ }
+ val androidDeviceTest by getting {
+ dependencies {
+ implementation(libs.androidx.test.ext.junit)
+ implementation(libs.androidx.test.runner)
+ }
+ }
}
}
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
similarity index 100%
rename from core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
rename to core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt
similarity index 100%
rename from core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt
rename to core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/ChannelSetTest.kt
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt b/core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
similarity index 100%
rename from core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
rename to core/model/src/androidDeviceTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
similarity index 56%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
index e1ffb313a..40f35ece2 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.model
+import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -25,60 +26,74 @@ class CapabilitiesTest {
private fun caps(version: String?) = Capabilities(version, forceEnableAll = false)
@Test
- fun `canMuteNode requires v2 7 18`() {
+ fun canMuteNodeRequiresV2718() {
assertFalse(caps("2.7.15").canMuteNode)
assertTrue(caps("2.7.18").canMuteNode)
assertTrue(caps("2.8.0").canMuteNode)
- assertTrue(caps("2.8.1").canMuteNode)
}
- // FIXME: needs updating when NeighborInfo is working properly
@Test
- fun `canRequestNeighborInfo disabled`() {
+ fun canRequestNeighborInfoIsCurrentlyDisabled() {
assertFalse(caps("2.7.14").canRequestNeighborInfo)
- assertFalse(caps("2.7.15").canRequestNeighborInfo)
- assertFalse(caps("2.8.0").canRequestNeighborInfo)
+ assertFalse(caps("3.0.0").canRequestNeighborInfo)
}
@Test
- fun `canSendVerifiedContacts requires v2 7 12`() {
+ fun canSendVerifiedContactsRequiresV2712() {
assertFalse(caps("2.7.11").canSendVerifiedContacts)
assertTrue(caps("2.7.12").canSendVerifiedContacts)
- assertTrue(caps("2.7.15").canSendVerifiedContacts)
}
@Test
- fun `canToggleTelemetryEnabled requires v2 7 12`() {
+ fun canToggleTelemetryEnabledRequiresV2712() {
assertFalse(caps("2.7.11").canToggleTelemetryEnabled)
assertTrue(caps("2.7.12").canToggleTelemetryEnabled)
}
@Test
- fun `canToggleUnmessageable requires v2 6 9`() {
+ fun canToggleUnmessageableRequiresV269() {
assertFalse(caps("2.6.8").canToggleUnmessageable)
assertTrue(caps("2.6.9").canToggleUnmessageable)
}
@Test
- fun `supportsQrCodeSharing requires v2 6 8`() {
+ fun supportsQrCodeSharingRequiresV268() {
assertFalse(caps("2.6.7").supportsQrCodeSharing)
assertTrue(caps("2.6.8").supportsQrCodeSharing)
}
@Test
- fun `supportsSecondaryChannelLocation requires v2 6 10`() {
+ fun supportsSecondaryChannelLocationRequiresV2610() {
assertFalse(caps("2.6.9").supportsSecondaryChannelLocation)
assertTrue(caps("2.6.10").supportsSecondaryChannelLocation)
}
@Test
- fun `supportsStatusMessage requires v2 7 17`() {
+ fun supportsStatusMessageRequiresV2717() {
assertFalse(caps("2.7.16").supportsStatusMessage)
assertTrue(caps("2.7.17").supportsStatusMessage)
}
@Test
- fun `null firmware returns all false`() {
+ fun supportsTrafficManagementConfigRequiresV300() {
+ assertFalse(caps("2.7.18").supportsTrafficManagementConfig)
+ assertTrue(caps("3.0.0").supportsTrafficManagementConfig)
+ }
+
+ @Test
+ fun supportsTakConfigRequiresV2719() {
+ assertFalse(caps("2.7.18").supportsTakConfig)
+ assertTrue(caps("2.7.19").supportsTakConfig)
+ }
+
+ @Test
+ fun supportsEsp32OtaRequiresV2718() {
+ assertFalse(caps("2.7.17").supportsEsp32Ota)
+ assertTrue(caps("2.7.18").supportsEsp32Ota)
+ }
+
+ @Test
+ fun nullFirmwareReturnsAllFalse() {
val c = caps(null)
assertFalse(c.canMuteNode)
assertFalse(c.canRequestNeighborInfo)
@@ -88,44 +103,35 @@ class CapabilitiesTest {
assertFalse(c.supportsQrCodeSharing)
assertFalse(c.supportsSecondaryChannelLocation)
assertFalse(c.supportsStatusMessage)
+ assertFalse(c.supportsTrafficManagementConfig)
+ assertFalse(c.supportsTakConfig)
+ assertFalse(c.supportsEsp32Ota)
}
@Test
- fun `invalid firmware returns all false`() {
- val c = caps("invalid")
- assertFalse(c.canMuteNode)
- assertFalse(c.canRequestNeighborInfo)
- assertFalse(c.canSendVerifiedContacts)
- assertFalse(c.canToggleTelemetryEnabled)
- assertFalse(c.canToggleUnmessageable)
- assertFalse(c.supportsQrCodeSharing)
- assertFalse(c.supportsSecondaryChannelLocation)
- assertFalse(c.supportsStatusMessage)
- }
-
- @Test
- fun `forceEnableAll returns true for everything regardless of version`() {
+ fun forceEnableAllReturnsTrueForEverythingRegardlessOfVersion() {
val c = Capabilities(firmwareVersion = null, forceEnableAll = true)
assertTrue(c.canMuteNode)
- assertTrue(c.canRequestNeighborInfo)
assertTrue(c.canSendVerifiedContacts)
- assertTrue(c.canToggleTelemetryEnabled)
- assertTrue(c.canToggleUnmessageable)
- assertTrue(c.supportsQrCodeSharing)
- assertTrue(c.supportsSecondaryChannelLocation)
assertTrue(c.supportsStatusMessage)
+ assertTrue(c.supportsTrafficManagementConfig)
+ assertTrue(c.supportsTakConfig)
}
@Test
- fun `forceEnableAll returns true even for invalid versions`() {
- val c = Capabilities(firmwareVersion = "invalid", forceEnableAll = true)
- assertTrue(c.canMuteNode)
- assertTrue(c.canRequestNeighborInfo)
- assertTrue(c.canSendVerifiedContacts)
- assertTrue(c.canToggleTelemetryEnabled)
- assertTrue(c.canToggleUnmessageable)
- assertTrue(c.supportsQrCodeSharing)
- assertTrue(c.supportsSecondaryChannelLocation)
- assertTrue(c.supportsStatusMessage)
+ fun deviceVersionParsingIsRobust() {
+ assertEquals(20712, DeviceVersion("2.7.12").asInt)
+ assertEquals(20712, DeviceVersion("2.7.12-beta").asInt)
+ assertEquals(30000, DeviceVersion("3.0.0").asInt)
+ assertEquals(20700, DeviceVersion("2.7").asInt) // Handles 2-part versions
+ assertEquals(0, DeviceVersion("invalid").asInt)
+ }
+
+ @Test
+ fun deviceVersionComparisonIsCorrect() {
+ assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11"))
+ assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1"))
+ assertTrue(DeviceVersion("2.7.12") == DeviceVersion("2.7.12"))
+ assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0"))
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
similarity index 100%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/ChannelOptionTest.kt
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
similarity index 76%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
index 94bf4f5a4..0d6d15c1d 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketParcelTest.kt
@@ -119,24 +119,24 @@ class DataPacketParcelTest {
)
private fun assertDataPacketsEqual(expected: DataPacket, actual: DataPacket) {
- assertEquals("to", expected.to, actual.to)
- assertEquals("bytes", expected.bytes, actual.bytes)
- assertEquals("dataType", expected.dataType, actual.dataType)
- assertEquals("from", expected.from, actual.from)
- assertEquals("time", expected.time, actual.time)
- assertEquals("id", expected.id, actual.id)
- assertEquals("status", expected.status, actual.status)
- assertEquals("hopLimit", expected.hopLimit, actual.hopLimit)
- assertEquals("channel", expected.channel, actual.channel)
- assertEquals("wantAck", expected.wantAck, actual.wantAck)
- assertEquals("hopStart", expected.hopStart, actual.hopStart)
- assertEquals("snr", expected.snr, actual.snr, 0.001f)
- assertEquals("rssi", expected.rssi, actual.rssi)
- assertEquals("replyId", expected.replyId, actual.replyId)
- assertEquals("relayNode", expected.relayNode, actual.relayNode)
- assertEquals("relays", expected.relays, actual.relays)
- assertEquals("viaMqtt", expected.viaMqtt, actual.viaMqtt)
- assertEquals("emoji", expected.emoji, actual.emoji)
- assertEquals("sfppHash", expected.sfppHash, actual.sfppHash)
+ assertEquals(expected.to, actual.to)
+ assertEquals(expected.bytes, actual.bytes)
+ assertEquals(expected.dataType, actual.dataType)
+ assertEquals(expected.from, actual.from)
+ assertEquals(expected.time, actual.time)
+ assertEquals(expected.id, actual.id)
+ assertEquals(expected.status, actual.status)
+ assertEquals(expected.hopLimit, actual.hopLimit)
+ assertEquals(expected.channel, actual.channel)
+ assertEquals(expected.wantAck, actual.wantAck)
+ assertEquals(expected.hopStart, actual.hopStart)
+ assertEquals(expected.snr, actual.snr, 0.001f)
+ assertEquals(expected.rssi, actual.rssi)
+ assertEquals(expected.replyId, actual.replyId)
+ assertEquals(expected.relayNode, actual.relayNode)
+ assertEquals(expected.relays, actual.relays)
+ assertEquals(expected.viaMqtt, actual.viaMqtt)
+ assertEquals(expected.emoji, actual.emoji)
+ assertEquals(expected.sfppHash, actual.sfppHash)
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
similarity index 98%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
index 5dddd5858..5858585b4 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt
@@ -21,6 +21,7 @@ import kotlinx.serialization.json.Json
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -36,7 +37,7 @@ class DataPacketTest {
assertEquals(hash, packet.sfppHash)
val packetNoHash = DataPacket(to = "to", channel = 0, text = "hello")
- assertEquals(null, packetNoHash.sfppHash)
+ assertNull(packetNoHash.sfppHash)
}
@Test
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
similarity index 100%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/DeviceVersionTest.kt
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
similarity index 73%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
index 0d10a6426..22942787a 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/NodeInfoTest.kt
@@ -18,7 +18,7 @@ package org.meshtastic.core.model
import androidx.core.os.LocaleListCompat
import org.junit.After
-import org.junit.Assert
+import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.Config
@@ -50,16 +50,16 @@ class NodeInfoTest {
@Test
fun distanceGood() {
- Assert.assertEquals(node[1].distance(node[2]), 1111)
- Assert.assertEquals(node[1].distance(node[3]), 111)
- Assert.assertEquals(node[1].distance(node[4]), 1779)
+ assertEquals(1111, node[1].distance(node[2]))
+ assertEquals(111, node[1].distance(node[3]))
+ assertEquals(1779, node[1].distance(node[4]))
}
@Test
fun distanceStrGood() {
- Assert.assertEquals(node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value), "1.1 km")
- Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value), "111 m")
- Assert.assertEquals(node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "1.1 mi")
- Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "364 ft")
+ assertEquals("1.1 km", node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value))
+ assertEquals("111 m", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value))
+ assertEquals("1.1 mi", node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
+ assertEquals("364 ft", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt
similarity index 72%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt
index f07ad83dd..e6b44cd27 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/PositionTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/PositionTest.kt
@@ -16,22 +16,23 @@
*/
package org.meshtastic.core.model
-import org.junit.Assert
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
import org.junit.Test
class PositionTest {
@Test
fun degGood() {
- Assert.assertEquals(Position.degI(89.0), 890000000)
- Assert.assertEquals(Position.degI(-89.0), -890000000)
+ assertEquals(Position.degI(89.0), 890000000)
+ assertEquals(Position.degI(-89.0), -890000000)
- Assert.assertEquals(Position.degD(Position.degI(89.0)), 89.0, 0.01)
- Assert.assertEquals(Position.degD(Position.degI(-89.0)), -89.0, 0.01)
+ assertEquals(89.0, Position.degD(Position.degI(89.0)), 0.01)
+ assertEquals(-89.0, Position.degD(Position.degI(-89.0)), 0.01)
}
@Test
fun givenPositionCreatedWithoutTime_thenTimeIsSet() {
val position = Position(37.1, 121.1, 35)
- Assert.assertTrue(position.time != 0)
+ assertTrue(position.time != 0)
}
}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
similarity index 92%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
index c73a65853..67df45ce7 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
@@ -58,7 +58,7 @@ class SharedContactTest {
assertEquals("Suzume", contact.user?.long_name)
}
- @Test(expected = java.net.MalformedURLException::class)
+ @Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidHostThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com")
@@ -66,7 +66,7 @@ class SharedContactTest {
url.toSharedContact()
}
- @Test(expected = java.net.MalformedURLException::class)
+ @Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidPathThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/wrong/")
@@ -74,21 +74,21 @@ class SharedContactTest {
url.toSharedContact()
}
- @Test(expected = java.net.MalformedURLException::class)
+ @Test(expected = MalformedMeshtasticUrlException::class)
fun testMissingFragmentThrows() {
val urlStr = "https://meshtastic.org/v/"
val url = Uri.parse(urlStr)
url.toSharedContact()
}
- @Test(expected = java.net.MalformedURLException::class)
+ @Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidBase64Throws() {
val urlStr = "https://meshtastic.org/v/#InvalidBase64!!!!"
val url = Uri.parse(urlStr)
url.toSharedContact()
}
- @Test(expected = java.net.MalformedURLException::class)
+ @Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidProtoThrows() {
// Tag 0 is invalid in Protobuf
// 0x00 -> Tag 0, Type 0.
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
similarity index 97%
rename from core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
rename to core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
index 2c729b1ba..606dc485d 100644
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
+++ b/core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util/UriUtilsTest.kt
@@ -32,7 +32,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles channel share uri`() {
- val uri = Uri.parse("https://meshtastic.org/e/somechannel")
+ val uri = Uri.parse("https://meshtastic.org/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle channel URI", handled)
@@ -41,7 +41,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles contact share uri`() {
- val uri = Uri.parse("https://meshtastic.org/v/somecontact")
+ val uri = Uri.parse("https://meshtastic.org/v/somecontact").toCommonUri()
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle contact URI", handled)
@@ -50,21 +50,21 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri ignores other hosts`() {
- val uri = Uri.parse("https://example.com/e/somechannel")
+ val uri = Uri.parse("https://example.com/e/somechannel").toCommonUri()
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle other hosts", handled)
}
@Test
fun `handleMeshtasticUri ignores other paths`() {
- val uri = Uri.parse("https://meshtastic.org/other/path")
+ val uri = Uri.parse("https://meshtastic.org/other/path").toCommonUri()
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle unknown paths", handled)
}
@Test
fun `handleMeshtasticUri handles case insensitivity`() {
- val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel")
+ val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle mixed case URI", handled)
@@ -73,7 +73,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles www host`() {
- val uri = Uri.parse("https://www.meshtastic.org/e/somechannel")
+ val uri = Uri.parse("https://www.meshtastic.org/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle www host", handled)
@@ -82,7 +82,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles long channel path`() {
- val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel")
+ val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle long channel path", handled)
@@ -91,7 +91,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles long contact path`() {
- val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact")
+ val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact").toCommonUri()
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle long contact path", handled)
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
deleted file mode 100644
index ae4690a52..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/ExtensionsTest.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * 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.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.meshtastic.proto.EnvironmentMetrics
-import org.meshtastic.proto.MeshPacket
-import org.meshtastic.proto.Telemetry
-
-class ExtensionsTest {
-
- @Test
- fun `isDirectSignal returns true for valid LoRa non-MQTT packets with matching hops`() {
- val packet =
- MeshPacket(
- rx_time = 123456,
- hop_start = 3,
- hop_limit = 3,
- via_mqtt = false,
- transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
- )
- assertTrue(packet.isDirectSignal())
- }
-
- @Test
- fun `isDirectSignal returns false if via MQTT`() {
- val packet =
- MeshPacket(
- rx_time = 123456,
- hop_start = 3,
- hop_limit = 3,
- via_mqtt = true,
- transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
- )
- assertFalse(packet.isDirectSignal())
- }
-
- @Test
- fun `isDirectSignal returns false if hops do not match`() {
- val packet =
- MeshPacket(
- rx_time = 123456,
- hop_start = 3,
- hop_limit = 2,
- via_mqtt = false,
- transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
- )
- assertFalse(packet.isDirectSignal())
- }
-
- @Test
- fun `isDirectSignal returns false if rx_time is zero`() {
- val packet =
- MeshPacket(
- rx_time = 0,
- hop_start = 3,
- hop_limit = 3,
- via_mqtt = false,
- transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
- )
- assertFalse(packet.isDirectSignal())
- }
-
- @Test
- fun `hasValidEnvironmentMetrics returns true when temperature and humidity are present and valid`() {
- val telemetry =
- Telemetry(environment_metrics = EnvironmentMetrics(temperature = 25.0f, relative_humidity = 50.0f))
- assertTrue(telemetry.hasValidEnvironmentMetrics())
- }
-
- @Test
- fun `hasValidEnvironmentMetrics returns false if temperature is NaN`() {
- val telemetry =
- Telemetry(environment_metrics = EnvironmentMetrics(temperature = Float.NaN, relative_humidity = 50.0f))
- assertFalse(telemetry.hasValidEnvironmentMetrics())
- }
-
- @Test
- fun `hasValidEnvironmentMetrics returns false if humidity is missing`() {
- val telemetry =
- Telemetry(environment_metrics = EnvironmentMetrics(temperature = 25.0f, relative_humidity = null))
- assertFalse(telemetry.hasValidEnvironmentMetrics())
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
deleted file mode 100644
index 218955a2f..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-/*
- * 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.junit.Assert.assertArrayEquals
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotEquals
-import org.junit.Test
-
-class SfppHasherTest {
-
- @Test
- fun `computeMessageHash produces consistent results`() {
- val payload = "Hello World".toByteArray()
- val to = 1234
- val from = 5678
- val id = 999
-
- val hash1 = SfppHasher.computeMessageHash(payload, to, from, id)
- val hash2 = SfppHasher.computeMessageHash(payload, to, from, id)
-
- assertArrayEquals(hash1, hash2)
- assertEquals(16, hash1.size)
- }
-
- @Test
- fun `computeMessageHash produces different results for different inputs`() {
- val payload = "Hello World".toByteArray()
- val to = 1234
- val from = 5678
- val id = 999
-
- val hashBase = SfppHasher.computeMessageHash(payload, to, from, id)
-
- // Different payload
- val hashDiffPayload = SfppHasher.computeMessageHash("Hello Work".toByteArray(), to, from, id)
- assertNotEquals(hashBase.toList(), hashDiffPayload.toList())
-
- // Different to
- val hashDiffTo = SfppHasher.computeMessageHash(payload, 1235, from, id)
- assertNotEquals(hashBase.toList(), hashDiffTo.toList())
-
- // Different from
- val hashDiffFrom = SfppHasher.computeMessageHash(payload, to, 5679, id)
- assertNotEquals(hashBase.toList(), hashDiffFrom.toList())
-
- // Different id
- val hashDiffId = SfppHasher.computeMessageHash(payload, to, from, 1000)
- assertNotEquals(hashBase.toList(), hashDiffId.toList())
- }
-
- @Test
- fun `computeMessageHash handles large values`() {
- val payload = byteArrayOf(1, 2, 3)
- // Testing that large unsigned-like values don't cause issues
- val to = -1 // 0xFFFFFFFF
- val from = 0x7FFFFFFF
- val id = Int.MIN_VALUE
-
- val hash = SfppHasher.computeMessageHash(payload, to, from, id)
- assertEquals(16, hash.size)
- }
-
- @Test
- fun `computeMessageHash follows little endian for integers`() {
- // This test ensures that the hash is computed consistently with the firmware
- // which uses little-endian byte order for these fields.
- val payload = byteArrayOf()
- val to = 0x01020304
- val from = 0x05060708
- val id = 0x090A0B0C
-
- val hash = SfppHasher.computeMessageHash(payload, to, from, id)
- assertNotNull(hash)
- assertEquals(16, hash.size)
- }
-
- private fun assertNotNull(any: Any?) {
- if (any == null) throw AssertionError("Should not be null")
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
deleted file mode 100644
index 68ea8032e..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * 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 kotlinx.datetime.TimeZone
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.meshtastic.core.common.util.await
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.common.util.secondsToInstant
-import org.meshtastic.core.common.util.toDate
-import org.meshtastic.core.common.util.toInstant
-import java.util.concurrent.CountDownLatch
-import kotlin.time.Clock
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Duration.Companion.seconds
-import kotlin.time.Instant
-
-class TimeExtensionsTest {
-
- @Test
- fun testNowMillis() {
- val start = Clock.System.now().toEpochMilliseconds()
- val now = nowMillis
- val end = Clock.System.now().toEpochMilliseconds()
- assertTrue(now in start..end)
- }
-
- @Test
- fun testNowSeconds() {
- val start = Clock.System.now().epochSeconds
- val now = nowSeconds
- val end = Clock.System.now().epochSeconds
- assertTrue(now in start..end)
- }
-
- @Test
- fun testToDate() {
- val instant = Instant.fromEpochMilliseconds(1234567890L)
- val date = instant.toDate()
- assertEquals(1234567890L, date.time)
- }
-
- @Test
- fun testLongToInstant() {
- val millis = 1234567890L
- val instant = millis.toInstant()
- assertEquals(millis, instant.toEpochMilliseconds())
- }
-
- @Test
- fun testIntSecondsToInstant() {
- val seconds = 1234567890
- val instant = seconds.secondsToInstant()
- assertEquals(seconds.toLong(), instant.epochSeconds)
- }
-
- @Test
- fun testDurationInWholeSeconds() {
- assertEquals(60L, 60.seconds.inWholeSeconds)
- assertEquals(3600L, TimeConstants.ONE_HOUR.inWholeSeconds)
- }
-
- @Test
- fun testLongSecondsProperty() {
- assertEquals(60.seconds, 60L.seconds)
- }
-
- @Test
- fun testCountDownLatchAwaitWithDuration() {
- val latch = CountDownLatch(1)
- // This should timeout quickly
- val result = latch.await(10.milliseconds)
- assertEquals(false, result)
-
- val latch2 = CountDownLatch(1)
- latch2.countDown()
- val result2 = latch2.await(1.seconds)
- assertEquals(true, result2)
- }
-
- @Test
- fun testTimeZoneToPosixString() {
- val tz = TimeZone.of("UTC")
- assertEquals("UTC0", tz.toPosixString())
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
deleted file mode 100644
index 07832a903..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/UnitConversionsTest.kt
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * 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.junit.Assert.assertEquals
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.meshtastic.core.model.util.UnitConversions.toTempString
-
-class UnitConversionsTest {
-
- // Test data: (celsius, isFahrenheit, expected)
- private val tempTestCases =
- listOf(
- // Issue #4150: negative zero should display as "0"
- Triple(-0.1f, false, "0°C"),
- Triple(-0.2f, false, "0°C"),
- Triple(-0.4f, false, "0°C"),
- Triple(-0.49f, false, "0°C"),
- // Boundary: -0.5 rounds to -1
- Triple(-0.5f, false, "-1°C"),
- Triple(-0.9f, false, "-1°C"),
- Triple(-1.0f, false, "-1°C"),
- // Zero and small positives
- Triple(0.0f, false, "0°C"),
- Triple(0.1f, false, "0°C"),
- Triple(0.4f, false, "0°C"),
- // Typical values
- Triple(1.0f, false, "1°C"),
- Triple(20.0f, false, "20°C"),
- Triple(25.4f, false, "25°C"),
- Triple(25.5f, false, "26°C"),
- // Negative
- Triple(-5.0f, false, "-5°C"),
- Triple(-10.0f, false, "-10°C"),
- Triple(-20.4f, false, "-20°C"),
- // Fahrenheit conversions
- Triple(0.0f, true, "32°F"),
- Triple(20.0f, true, "68°F"),
- Triple(25.0f, true, "77°F"),
- Triple(100.0f, true, "212°F"),
- Triple(-40.0f, true, "-40°F"), // -40°C = -40°F
- // Issue #4150: negative zero in Fahrenheit
- Triple(-0.1f, true, "32°F"),
- Triple(-17.78f, true, "0°F"),
- )
-
- @Test
- fun `toTempString formats all temperatures correctly`() {
- tempTestCases.forEach { (celsius, isFahrenheit, expected) ->
- assertEquals(
- "Failed for $celsius°C (Fahrenheit=$isFahrenheit)",
- expected,
- celsius.toTempString(isFahrenheit),
- )
- }
- }
-
- @Test
- fun `toTempString handles extreme temperatures`() {
- assertEquals("100°C", 100.0f.toTempString(false))
- assertEquals("-40°C", (-40.0f).toTempString(false))
- assertEquals("-40°F", (-40.0f).toTempString(true))
- }
-
- @Test
- fun `toTempString handles NaN`() {
- assertEquals("--", Float.NaN.toTempString(false))
- assertEquals("--", Float.NaN.toTempString(true))
- }
-
- @Test
- fun `celsiusToFahrenheit converts correctly`() {
- mapOf(
- 0.0f to 32.0f,
- 20.0f to 68.0f,
- 100.0f to 212.0f,
- -40.0f to -40.0f,
- ).forEach { (celsius, expectedFahrenheit) ->
- assertEquals(expectedFahrenheit, UnitConversions.celsiusToFahrenheit(celsius), 0.01f)
- }
- }
-
- @Test
- fun `calculateDewPoint returns expected values`() {
- // At 100% humidity, dew point equals temperature
- assertEquals(20.0f, UnitConversions.calculateDewPoint(20.0f, 100.0f), 0.1f)
-
- // Known reference: 20°C at 60% humidity ≈ 12°C dew point
- assertEquals(12.0f, UnitConversions.calculateDewPoint(20.0f, 60.0f), 0.5f)
-
- // Higher humidity = higher dew point
- val highHumidity = UnitConversions.calculateDewPoint(25.0f, 80.0f)
- val lowHumidity = UnitConversions.calculateDewPoint(25.0f, 40.0f)
- assertTrue("Dew point should be higher at higher humidity", highHumidity > lowHumidity)
- }
-
- @Test
- fun `calculateDewPoint handles edge cases`() {
- // 0% humidity results in NaN (ln(0) = -Infinity, causing invalid calculation)
- val zeroHumidity = UnitConversions.calculateDewPoint(20.0f, 0.0f)
- assertTrue("Expected NaN for 0% humidity", zeroHumidity.isNaN())
- }
-}
diff --git a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt b/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
deleted file mode 100644
index b9ede858f..000000000
--- a/core/model/src/androidUnitTest/kotlin/org/meshtastic/core/model/util/WireExtensionsTest.kt
+++ /dev/null
@@ -1,336 +0,0 @@
-/*
- * 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 co.touchlab.kermit.Logger
-import okio.ByteString
-import okio.ByteString.Companion.toByteString
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.meshtastic.proto.DeviceMetrics
-import org.meshtastic.proto.Position
-import org.meshtastic.proto.Telemetry
-import org.meshtastic.proto.User
-
-/**
- * Unit tests for Wire extension functions.
- *
- * Tests safe decoding, size validation, and JSON marshalling extensions to ensure proper error handling and
- * functionality.
- */
-class WireExtensionsTest {
-
- private val testLogger = Logger
-
- @Before
- fun setUp() {
- // Setup test logger if needed
- }
-
- // ===== decodeOrNull() Tests =====
-
- @Test
- fun `decodeOrNull with valid ByteString returns decoded message`() {
- // Arrange
- val position = Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15)
- val encoded = Position.ADAPTER.encode(position)
- val byteString = encoded.toByteString()
-
- // Act
- val decoded = Position.ADAPTER.decodeOrNull(byteString, testLogger)
-
- // Assert
- assertNotNull(decoded)
- assertEquals(position.latitude_i, decoded!!.latitude_i)
- assertEquals(position.longitude_i, decoded.longitude_i)
- assertEquals(position.altitude, decoded.altitude)
- }
-
- @Test
- fun `decodeOrNull with null ByteString returns null`() {
- // Act
- val result = Position.ADAPTER.decodeOrNull(null as ByteString?, testLogger)
-
- // Assert
- assertNull(result)
- }
-
- @Test
- fun `decodeOrNull with empty ByteString returns empty message`() {
- // Act
- val result = Position.ADAPTER.decodeOrNull(ByteString.EMPTY, testLogger)
-
- // Assert
- assertNotNull(result)
- // An empty position should have null/default values
- assertNull(result!!.latitude_i)
- }
-
- @Test
- fun `decodeOrNull with valid ByteArray returns decoded message`() {
- // Arrange
- val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
- val encoded = Position.ADAPTER.encode(position)
-
- // Act
- val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger)
-
- // Assert
- assertNotNull(decoded)
- assertEquals(position.latitude_i, decoded!!.latitude_i)
- assertEquals(position.longitude_i, decoded.longitude_i)
- }
-
- @Test
- fun `decodeOrNull with null ByteArray returns null`() {
- // Act
- val result = Position.ADAPTER.decodeOrNull(null as ByteArray?, testLogger)
-
- // Assert
- assertNull(result)
- }
-
- @Test
- fun `decodeOrNull with empty ByteArray returns empty message`() {
- // Act
- val result = Position.ADAPTER.decodeOrNull(ByteArray(0), testLogger)
-
- // Assert
- assertNotNull(result)
- assertNull(result!!.latitude_i)
- }
-
- @Test
- fun `decodeOrNull with invalid data returns null`() {
- // Arrange
- // A single byte 0xFF is an invalid field tag (field 0 is reserved and tags are varints)
- val invalidBytes = ByteString.of(0xFF.toByte())
-
- // Act - should not throw, should return null
- val result = Position.ADAPTER.decodeOrNull(invalidBytes, testLogger)
-
- // Assert
- assertNull(result)
- }
-
- // ===== Size Validation Tests =====
-
- @Test
- fun `isWithinSizeLimit returns true for message under limit`() {
- // Arrange
- val position = Position(latitude_i = 371234567)
- val limit = 1000
-
- // Act
- val isValid = Position.ADAPTER.isWithinSizeLimit(position, limit)
-
- // Assert
- assertTrue(isValid)
- }
-
- @Test
- fun `isWithinSizeLimit returns false for message over limit`() {
- // Arrange
- val telemetry =
- Telemetry(
- device_metrics =
- DeviceMetrics(voltage = 4.2f, battery_level = 85, air_util_tx = 5.0f, channel_utilization = 15.0f),
- )
- val limit = 1 // Artificially low limit
-
- // Act
- val isValid = Telemetry.ADAPTER.isWithinSizeLimit(telemetry, limit)
-
- // Assert
- assertEquals(false, isValid)
- }
-
- @Test
- fun `sizeInBytes returns accurate encoded size`() {
- // Arrange
- val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
-
- // Act
- val size = Position.ADAPTER.sizeInBytes(position)
- val actualEncoded = Position.ADAPTER.encode(position)
-
- // Assert
- assertEquals(actualEncoded.size, size)
- assertTrue(size > 0)
- }
-
- @Test
- fun `sizeInBytes for empty message`() {
- // Arrange
- val emptyPosition = Position()
-
- // Act
- val size = Position.ADAPTER.sizeInBytes(emptyPosition)
-
- // Assert
- assertTrue(size >= 0)
- }
-
- @Test
- fun `sizeInBytes matches wire encoding size`() {
- // Arrange
- val user = User(id = "12345", long_name = "Test User", short_name = "TU")
-
- // Act
- val extensionSize = User.ADAPTER.sizeInBytes(user)
- val actualEncoded = User.ADAPTER.encode(user)
-
- // Assert
- assertEquals(extensionSize, actualEncoded.size)
- }
-
- // ===== JSON Marshalling Tests =====
-
- @Test
- fun `toReadableString returns non-empty string`() {
- // Arrange
- val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
-
- // Act
- val readable = Position.ADAPTER.toReadableString(position)
-
- // Assert
- assertNotNull(readable)
- assertTrue(readable.isNotEmpty())
- assertTrue(readable.contains("Position"))
- }
-
- @Test
- fun `toReadableString contains field values`() {
- // Arrange
- val position = Position(latitude_i = 12345, longitude_i = 67890)
-
- // Act
- val readable = Position.ADAPTER.toReadableString(position)
-
- // Assert
- assertTrue(readable.contains("12345"))
- assertTrue(readable.contains("67890"))
- }
-
- @Test
- fun `toOneLiner returns single line string`() {
- // Arrange
- val telemetry = Telemetry(device_metrics = DeviceMetrics(voltage = 4.2f))
-
- // Act
- val oneLiner = Telemetry.ADAPTER.toOneLiner(telemetry)
-
- // Assert
- assertNotNull(oneLiner)
- assertEquals(false, oneLiner.contains("\n"))
- assertTrue(oneLiner.isNotEmpty())
- }
-
- @Test
- fun `toOneLiner contains essential data`() {
- // Arrange
- val user = User(long_name = "Test User")
-
- // Act
- val oneLiner = User.ADAPTER.toOneLiner(user)
-
- // Assert
- assertTrue(oneLiner.contains("Test User"))
- }
-
- // ===== Integration Tests =====
-
- @Test
- fun `decode and encode roundtrip maintains data`() {
- // Arrange
- val originalPosition =
- Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15, precision_bits = 5)
- val encoded = Position.ADAPTER.encode(originalPosition)
-
- // Act
- val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger)
-
- // Assert
- assertNotNull(decoded)
- assertEquals(originalPosition.latitude_i, decoded!!.latitude_i)
- assertEquals(originalPosition.longitude_i, decoded.longitude_i)
- assertEquals(originalPosition.altitude, decoded.altitude)
- assertEquals(originalPosition.precision_bits, decoded.precision_bits)
- }
-
- @Test
- fun `size checking prevents oversized messages`() {
- // Arrange
- val position = Position(latitude_i = 123456789, longitude_i = 987654321, altitude = 100)
- val maxSize = 5 // Very small limit
-
- // Act
- val isValid = Position.ADAPTER.isWithinSizeLimit(position, maxSize)
- val actualSize = Position.ADAPTER.sizeInBytes(position)
-
- // Assert
- assertEquals(false, isValid)
- assertTrue(actualSize > maxSize)
- }
-
- @Test
- fun `multiple messages with different sizes`() {
- // Arrange
- val smallUser = User(short_name = "A")
- val largeUser = User(long_name = "Very Long Name " + "X".repeat(100))
-
- // Act
- val smallSize = User.ADAPTER.sizeInBytes(smallUser)
- val largeSize = User.ADAPTER.sizeInBytes(largeUser)
-
- // Assert
- assertTrue(smallSize < largeSize)
- assertTrue(largeSize > smallSize)
- }
-
- @Test
- fun `readable string format consistency`() {
- // Arrange
- val position = Position(latitude_i = 123456)
-
- // Act
- val readable1 = Position.ADAPTER.toReadableString(position)
- val readable2 = Position.ADAPTER.toReadableString(position)
-
- // Assert
- assertEquals(readable1, readable2)
- }
-
- @Test
- fun `oneLiner format consistency`() {
- // Arrange
- val user = User(long_name = "Test")
-
- // Act
- val line1 = User.ADAPTER.toOneLiner(user)
- val line2 = User.ADAPTER.toOneLiner(user)
-
- // Assert
- assertEquals(line1, line2)
- assertEquals(false, line1.contains("\n"))
- }
-}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
index e5c069fc9..65096604f 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
@@ -23,50 +23,56 @@ import org.meshtastic.core.model.util.isDebug
*
* 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.
+ *
+ * Note: Properties are calculated once during initialization for efficiency.
*/
data class Capabilities(val firmwareVersion: String?, internal val forceEnableAll: Boolean = isDebug) {
private val version = firmwareVersion?.let { DeviceVersion(it) }
- private fun isSupported(minVersion: String): Boolean =
- forceEnableAll || (version != null && version >= DeviceVersion(minVersion))
+ private fun atLeast(min: DeviceVersion): Boolean = forceEnableAll || (version != null && version >= min)
- /**
- * Ability to mute notifications from specific nodes via admin messages.
- *
- * Note: This is currently not available in firmware but defined here for future support.
- */
- val canMuteNode: Boolean
- get() = isSupported("2.7.18")
+ /** Ability to mute notifications from specific nodes via admin messages. */
+ val canMuteNode = atLeast(V2_7_18)
/** FIXME: Ability to request neighbor information from other nodes. Disabled until working better. */
- val canRequestNeighborInfo: Boolean
- get() = isSupported("9.9.9")
+ val canRequestNeighborInfo = atLeast(UNRELEASED)
/** Ability to send verified shared contacts. Supported since firmware v2.7.12. */
- val canSendVerifiedContacts: Boolean
- get() = isSupported("2.7.12")
+ val canSendVerifiedContacts = atLeast(V2_7_12)
/** Ability to toggle device telemetry globally via module config. Supported since firmware v2.7.12. */
- val canToggleTelemetryEnabled: Boolean
- get() = isSupported("2.7.12")
+ val canToggleTelemetryEnabled = atLeast(V2_7_12)
/** Ability to toggle the 'is_unmessageable' flag in user config. Supported since firmware v2.6.9. */
- val canToggleUnmessageable: Boolean
- get() = isSupported("2.6.9")
+ val canToggleUnmessageable = atLeast(V2_6_9)
/** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
- val supportsQrCodeSharing: Boolean
- get() = isSupported("2.6.8")
+ val supportsQrCodeSharing = atLeast(V2_6_8)
/** Support for Status Message module. Supported since firmware v2.7.17. */
- val supportsStatusMessage: Boolean
- get() = isSupported("2.7.17")
+ val supportsStatusMessage = atLeast(V2_7_17)
+
+ /** Support for Traffic Management module. Supported since firmware v3.0.0. */
+ val supportsTrafficManagementConfig = atLeast(V3_0_0)
+
+ /** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */
+ val supportsTakConfig = atLeast(V2_7_19)
/** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */
- val supportsSecondaryChannelLocation: Boolean
- get() = isSupported("2.6.10")
+ val supportsSecondaryChannelLocation = atLeast(V2_6_10)
/** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */
- val supportsEsp32Ota: Boolean
- get() = isSupported("2.7.18")
+ val supportsEsp32Ota = atLeast(V2_7_18)
+
+ companion object {
+ private val V2_6_8 = DeviceVersion("2.6.8")
+ private val V2_6_9 = DeviceVersion("2.6.9")
+ private val V2_6_10 = DeviceVersion("2.6.10")
+ private val V2_7_12 = DeviceVersion("2.7.12")
+ private val V2_7_17 = DeviceVersion("2.7.17")
+ private val V2_7_18 = DeviceVersion("2.7.18")
+ private val V2_7_19 = DeviceVersion("2.7.19")
+ private val V3_0_0 = DeviceVersion("3.0.0")
+ private val UNRELEASED = DeviceVersion("9.9.9")
+ }
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
index 64d210f5d..d72d7775f 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt
@@ -21,15 +21,15 @@ import co.touchlab.kermit.Logger
/** Provide structured access to parse and compare device version strings */
data class DeviceVersion(val asString: String) : Comparable {
+ /** The integer representation of the version (e.g., 2.7.12 -> 20712). Calculated once. */
@Suppress("TooGenericExceptionCaught", "SwallowedException")
- val asInt
- get() =
- try {
- verStringToInt(asString)
- } catch (e: Exception) {
- Logger.w { "Exception while parsing version '$asString', assuming version 0" }
- 0
- }
+ val asInt: Int =
+ try {
+ verStringToInt(asString)
+ } catch (e: Exception) {
+ Logger.w { "Exception while parsing version '$asString', assuming version 0" }
+ 0
+ }
/**
* Convert a version string of the form 1.23.57 to a comparable integer of the form 12357.
@@ -51,5 +51,5 @@ data class DeviceVersion(val asString: String) : Comparable {
return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt()
}
- override fun compareTo(other: DeviceVersion): Int = asInt - other.asInt
+ override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt)
}
diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
index d3a43e392..7aba5f310 100644
--- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -145,6 +145,10 @@ object SettingsRoutes {
@Serializable data object StatusMessage : Route
+ @Serializable data object TrafficManagement : Route
+
+ @Serializable data object TAK : Route
+
// endregion
// region advanced config routes
diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts
index 347c9d69a..b2e255c4a 100644
--- a/core/resources/build.gradle.kts
+++ b/core/resources/build.gradle.kts
@@ -22,7 +22,10 @@ plugins {
kotlin {
@Suppress("UnstableApiUsage")
- android { androidResources.enable = true }
+ android {
+ androidResources.enable = true
+ withHostTest { isIncludeAndroidResources = true }
+ }
sourceSets { commonTest.dependencies { implementation(kotlin("test")) } }
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 7376bd0a0..b77231ac7 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1223,4 +1223,52 @@
Invalid name, URL template, or local URI for custom tile provider.
A custom tile provider with this name already exists.
Failed to copy MBTiles file to internal storage.
+
+ TAK (ATAK)
+ TAK Configuration
+ Team Color
+ Member Role
+
+ Unspecified
+ White
+ Yellow
+ Orange
+ Magenta
+ Red
+ Maroon
+ Purple
+ Dark Blue
+ Blue
+ Cyan
+ Teal
+ Green
+ Dark Green
+ Brown
+
+ Unspecified
+ Team Member
+ Team Lead
+ Headquarters
+ Sniper
+ Medic
+ Forward Observer
+ Radio Telephone Operator
+ Doggo (K9)
+
+ Traffic Management
+ Traffic Management Configuration
+ Module Enabled
+ Position Deduplication
+ Position Precision (bits)
+ Min Position Interval (secs)
+ NodeInfo Direct Response
+ Max Hops for Direct Response
+ Rate Limiting
+ Rate Limit Window (secs)
+ Max Packets in Window
+ Drop Unknown Packets
+ Unknown Packet Threshold
+ Local-only Telemetry (Relays)
+ Local-only Position (Relays)
+ Preserve Router Hops
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
index f6b5e6e64..33a454635 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
@@ -38,6 +38,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -51,6 +53,7 @@ fun > DropDownPreference(
modifier: Modifier = Modifier,
summary: String? = null,
itemIcon: @Composable ((T) -> ImageVector)? = null,
+ itemColor: @Composable ((T) -> Color)? = null,
itemLabel: @Composable ((T) -> String)? = null,
) {
val enumConstants =
@@ -63,7 +66,8 @@ fun > DropDownPreference(
enumConstants.map {
val label = itemLabel?.invoke(it) ?: it.name
val icon = itemIcon?.invoke(it)
- DropDownItem(it, label, icon)
+ val color = itemColor?.invoke(it)
+ DropDownItem(it, label, icon, color)
}
DropDownPreference(
@@ -77,7 +81,7 @@ fun > DropDownPreference(
)
}
-data class DropDownItem(val value: T, val label: String, val icon: ImageVector? = null)
+data class DropDownItem(val value: T, val label: String, val icon: ImageVector? = null, val color: Color? = null)
@JvmName("DropDownPreferencePairs")
@Composable
@@ -141,7 +145,17 @@ fun DropDownPreference(
modifier = Modifier.size(24.dp),
)
}
- },
+ }
+ ?: currentItem?.color?.let {
+ {
+ Icon(
+ painter = ColorPainter(it),
+ contentDescription = currentItem.label,
+ modifier = Modifier.size(24.dp),
+ tint = Color.Unspecified,
+ )
+ }
+ },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = ExposedDropdownMenuDefaults.textFieldColors(),
enabled = enabled,
@@ -157,8 +171,20 @@ fun DropDownPreference(
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
- selectionOption.icon?.let {
- Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(24.dp))
+ if (selectionOption.icon != null) {
+ Icon(
+ imageVector = selectionOption.icon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ } else if (selectionOption.color != null) {
+ Icon(
+ painter = ColorPainter(selectionOption.color),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = Color.Unspecified,
+ )
Spacer(modifier = Modifier.width(12.dp))
}
Text(selectionOption.label)
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
index cb96d573b..fd7eae24c 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
@@ -49,8 +49,11 @@ import org.meshtastic.core.resources.remote_hardware
import org.meshtastic.core.resources.serial
import org.meshtastic.core.resources.status_message
import org.meshtastic.core.resources.store_forward
+import org.meshtastic.core.resources.tak
import org.meshtastic.core.resources.telemetry
+import org.meshtastic.core.resources.traffic_management
import org.meshtastic.proto.AdminMessage
+import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
enum class ModuleRoute(
@@ -59,6 +62,7 @@ enum class ModuleRoute(
val icon: ImageVector?,
val type: Int = 0,
val isSupported: (Capabilities) -> Boolean = { true },
+ val isApplicable: (Config.DeviceConfig.Role?) -> Boolean = { true },
) {
MQTT(Res.string.mqtt, SettingsRoutes.MQTT, Icons.Rounded.Cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value),
SERIAL(
@@ -140,18 +144,51 @@ enum class ModuleRoute(
AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG.value,
isSupported = { it.supportsStatusMessage },
),
+ TRAFFIC_MANAGEMENT(
+ Res.string.traffic_management,
+ SettingsRoutes.TrafficManagement,
+ Icons.Rounded.Speed,
+ AdminMessage.ModuleConfigType.TRAFFICMANAGEMENT_CONFIG.value,
+ isSupported = { it.supportsTrafficManagementConfig },
+ ),
+ TAK(
+ Res.string.tak,
+ SettingsRoutes.TAK,
+ Icons.Rounded.People,
+ AdminMessage.ModuleConfigType.TAK_CONFIG.value,
+ isSupported = { it.supportsTakConfig },
+ isApplicable = { it == Config.DeviceConfig.Role.TAK || it == Config.DeviceConfig.Role.TAK_TRACKER },
+ ),
;
val bitfield: Int
- get() = 1 shl ordinal
+ get() =
+ when (this) {
+ MQTT -> 0x0001
+ SERIAL -> 0x0002
+ EXT_NOTIFICATION -> 0x0004
+ STORE_FORWARD -> 0x0008
+ RANGE_TEST -> 0x0010
+ TELEMETRY -> 0x0020
+ CANNED_MESSAGE -> 0x0040
+ AUDIO -> 0x0080
+ REMOTE_HARDWARE -> 0x0100
+ NEIGHBOR_INFO -> 0x0200
+ AMBIENT_LIGHTING -> 0x0400
+ DETECTION_SENSOR -> 0x0800
+ PAXCOUNTER -> 0x1000
+ STATUS_MESSAGE -> 0x0000 // Not excludable yet
+ TRAFFIC_MANAGEMENT -> 0x0000 // Not excludable yet
+ TAK -> 0x0000 // Not excludable yet
+ }
companion object {
- fun filterExcludedFrom(metadata: DeviceMetadata?): List {
+ fun filterExcludedFrom(metadata: DeviceMetadata?, role: Config.DeviceConfig.Role?): List {
val capabilities = Capabilities(metadata?.firmware_version)
return entries.filter {
val excludedModules = metadata?.excluded_modules ?: 0
val isExcluded = (excludedModules and it.bitfield) != 0
- !isExcluded && it.isSupported(capabilities)
+ !isExcluded && it.isSupported(capabilities) && it.isApplicable(role)
}
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
index e220b5c82..d84cad310 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
@@ -22,8 +22,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Download
-import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.CleaningServices
import androidx.compose.material.icons.rounded.Download
@@ -97,13 +95,15 @@ fun RadioConfigItemList(
onNavigate: (Route) -> Unit,
) {
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
- var modules by remember { mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata)) }
+ var modules by remember {
+ mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role))
+ }
- LaunchedEffect(excludedModulesUnlocked) {
+ LaunchedEffect(excludedModulesUnlocked, state.metadata, state.radioConfig.device?.role) {
if (excludedModulesUnlocked) {
modules = ModuleRoute.entries
} else {
- modules = ModuleRoute.filterExcludedFrom(state.metadata)
+ modules = ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role)
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 3f9adf6ee..ec9d29c5c 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -355,6 +355,8 @@ constructor(
detection_sensor = config.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = config.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = config.statusmessage ?: state.moduleConfig.statusmessage,
+ traffic_management = config.traffic_management ?: state.moduleConfig.traffic_management,
+ tak = config.tak ?: state.moduleConfig.tak,
),
)
}
@@ -591,6 +593,8 @@ constructor(
lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
+ lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) }
+ lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
}
meshService?.commitEditSettings(destNum)
}
@@ -823,6 +827,9 @@ constructor(
detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
+ traffic_management =
+ response.traffic_management ?: state.moduleConfig.traffic_management,
+ tak = response.tak ?: state.moduleConfig.tak,
),
)
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
new file mode 100644
index 000000000..94b17c645
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.feature.settings.radio.component
+
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.graphics.Color
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.database.model.getColorFrom
+import org.meshtastic.core.database.model.getStringResFrom
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.tak
+import org.meshtastic.core.resources.tak_config
+import org.meshtastic.core.resources.tak_role
+import org.meshtastic.core.resources.tak_team
+import org.meshtastic.core.ui.component.DropDownPreference
+import org.meshtastic.core.ui.component.TitledCard
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.proto.ModuleConfig
+
+@Composable
+fun TAKConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val takConfig = state.moduleConfig.tak ?: ModuleConfig.TAKConfig()
+ val formState = rememberConfigState(initialValue = takConfig)
+
+ LaunchedEffect(takConfig) { formState.value = takConfig }
+
+ RadioConfigScreenList(
+ title = stringResource(Res.string.tak),
+ onBack = onBack,
+ configState = formState,
+ enabled = state.connected,
+ responseState = state.responseState,
+ onDismissPacketResponse = viewModel::clearPacketResponse,
+ onSave = {
+ val config = ModuleConfig(tak = it)
+ viewModel.setModuleConfig(config)
+ },
+ ) {
+ item {
+ TitledCard(title = stringResource(Res.string.tak_config)) {
+ DropDownPreference(
+ title = stringResource(Res.string.tak_team),
+ enabled = state.connected,
+ selectedItem = formState.value.team,
+ itemLabel = { stringResource(getStringResFrom(it)) },
+ itemColor = { Color(getColorFrom(it)) },
+ onItemSelected = { formState.value = formState.value.copy(team = it) },
+ )
+ HorizontalDivider()
+ DropDownPreference(
+ title = stringResource(Res.string.tak_role),
+ enabled = state.connected,
+ selectedItem = formState.value.role,
+ itemLabel = { stringResource(getStringResFrom(it)) },
+ onItemSelected = { formState.value = formState.value.copy(role = it) },
+ )
+ }
+ }
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt
new file mode 100644
index 000000000..c05ff42d1
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TrafficManagementConfigItemList.kt
@@ -0,0 +1,208 @@
+/*
+ * 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.feature.settings.radio.component
+
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.traffic_management
+import org.meshtastic.core.resources.traffic_management_config
+import org.meshtastic.core.resources.traffic_management_drop_unknown_enabled
+import org.meshtastic.core.resources.traffic_management_enabled
+import org.meshtastic.core.resources.traffic_management_exhaust_hop_position
+import org.meshtastic.core.resources.traffic_management_exhaust_hop_telemetry
+import org.meshtastic.core.resources.traffic_management_nodeinfo_direct_response
+import org.meshtastic.core.resources.traffic_management_nodeinfo_direct_response_max_hops
+import org.meshtastic.core.resources.traffic_management_position_dedup
+import org.meshtastic.core.resources.traffic_management_position_min_interval
+import org.meshtastic.core.resources.traffic_management_position_precision
+import org.meshtastic.core.resources.traffic_management_rate_limit_enabled
+import org.meshtastic.core.resources.traffic_management_rate_limit_max_packets
+import org.meshtastic.core.resources.traffic_management_rate_limit_window
+import org.meshtastic.core.resources.traffic_management_router_preserve_hops
+import org.meshtastic.core.resources.traffic_management_unknown_packet_threshold
+import org.meshtastic.core.ui.component.EditTextPreference
+import org.meshtastic.core.ui.component.SwitchPreference
+import org.meshtastic.core.ui.component.TitledCard
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.proto.ModuleConfig
+
+@Suppress("LongMethod")
+@Composable
+fun TrafficManagementConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val tmConfig = state.moduleConfig.traffic_management ?: ModuleConfig.TrafficManagementConfig()
+ val formState = rememberConfigState(initialValue = tmConfig)
+ val focusManager = LocalFocusManager.current
+
+ LaunchedEffect(tmConfig) { formState.value = tmConfig }
+
+ RadioConfigScreenList(
+ title = stringResource(Res.string.traffic_management),
+ onBack = onBack,
+ configState = formState,
+ enabled = state.connected,
+ responseState = state.responseState,
+ onDismissPacketResponse = viewModel::clearPacketResponse,
+ onSave = {
+ val config = ModuleConfig(traffic_management = it)
+ viewModel.setModuleConfig(config)
+ },
+ ) {
+ item {
+ TitledCard(title = stringResource(Res.string.traffic_management_config)) {
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_enabled),
+ checked = formState.value.enabled,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_position_dedup),
+ checked = formState.value.position_dedup_enabled,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(position_dedup_enabled = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_position_precision),
+ value = formState.value.position_precision_bits,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(position_precision_bits = it) },
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_position_min_interval),
+ value = formState.value.position_min_interval_secs,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(position_min_interval_secs = it) },
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_nodeinfo_direct_response),
+ checked = formState.value.nodeinfo_direct_response,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(nodeinfo_direct_response = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_nodeinfo_direct_response_max_hops),
+ value = formState.value.nodeinfo_direct_response_max_hops,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(nodeinfo_direct_response_max_hops = it) },
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_rate_limit_enabled),
+ checked = formState.value.rate_limit_enabled,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(rate_limit_enabled = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_rate_limit_window),
+ value = formState.value.rate_limit_window_secs,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(rate_limit_window_secs = it) },
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_rate_limit_max_packets),
+ value = formState.value.rate_limit_max_packets,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(rate_limit_max_packets = it) },
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_drop_unknown_enabled),
+ checked = formState.value.drop_unknown_enabled,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(drop_unknown_enabled = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ EditTextPreference(
+ title = stringResource(Res.string.traffic_management_unknown_packet_threshold),
+ value = formState.value.unknown_packet_threshold,
+ enabled = state.connected,
+ keyboardActions =
+ KeyboardActions(
+ onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) },
+ ),
+ onValueChanged = { formState.value = formState.value.copy(unknown_packet_threshold = it) },
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_exhaust_hop_telemetry),
+ checked = formState.value.exhaust_hop_telemetry,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(exhaust_hop_telemetry = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_exhaust_hop_position),
+ checked = formState.value.exhaust_hop_position,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(exhaust_hop_position = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.traffic_management_router_preserve_hops),
+ checked = formState.value.router_preserve_hops,
+ enabled = state.connected,
+ onCheckedChange = { formState.value = formState.value.copy(router_preserve_hops = it) },
+ containerColor = CardDefaults.cardColors().containerColor,
+ )
+ }
+ }
+ }
+}
From fdd07f893f9cec47edc9aabcdfe8273e333c3457 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 2 Mar 2026 08:51:05 -0600
Subject: [PATCH 039/474] feat: settings rework (#4678)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../mesh/navigation/SettingsNavigation.kt | 41 ++-
.../repository/radio/RadioInterfaceService.kt | 8 +-
.../mesh/service/MeshCommandSender.kt | 4 +
.../geeksville/mesh/service/MeshService.kt | 8 +-
.../org/meshtastic/core/ble/BleConnection.kt | 6 +-
.../core/common/util/SequentialJob.kt | 25 +-
.../SwitchingNodeInfoReadDataSource.kt | 8 +-
.../SwitchingNodeInfoWriteDataSource.kt | 30 ++-
.../core/database/DatabaseManager.kt | 11 +-
.../org/meshtastic/core/navigation/Routes.kt | 6 +
.../composeResources/values/strings.xml | 1 +
.../core/service/ServiceRepository.kt | 2 +-
.../feature/node/metrics/NeighborInfoLog.kt | 3 +-
.../feature/node/metrics/TracerouteLog.kt | 3 +-
.../feature/settings/AdministrationScreen.kt | 191 ++++++++++++++
.../settings/DeviceConfigurationScreen.kt | 88 +++++++
.../settings/ModuleConfigurationScreen.kt | 99 +++++++
.../feature/settings/SettingsScreen.kt | 50 ++--
.../settings/filter/FilterSettingsScreen.kt | 6 +-
.../settings/navigation/ConfigRoute.kt | 2 +-
.../settings/radio/CleanNodeDatabaseScreen.kt | 8 +-
.../feature/settings/radio/RadioConfig.kt | 241 ++++++++----------
.../settings/radio/RadioConfigViewModel.kt | 25 +-
.../radio/channel/ChannelConfigScreen.kt | 32 ++-
.../radio/component/LoadingOverlay.kt | 97 +++++++
.../component/PacketResponseStateDialog.kt | 158 +++++++++---
.../radio/component/RadioConfigScreenList.kt | 94 +++----
27 files changed, 941 insertions(+), 306 deletions(-)
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
index 18522c531..eacec7cb3 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt
@@ -19,8 +19,11 @@
package com.geeksville.mesh.navigation
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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
@@ -32,7 +35,11 @@ import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.feature.settings.AboutScreen
+import org.meshtastic.feature.settings.AdministrationScreen
+import org.meshtastic.feature.settings.DeviceConfigurationScreen
+import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.SettingsScreen
+import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
import org.meshtastic.feature.settings.navigation.ConfigRoute
@@ -76,6 +83,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
val parentEntry =
remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
SettingsScreen(
+ settingsViewModel = hiltViewModel(parentEntry),
viewModel = hiltViewModel(parentEntry),
onClickNodeChip = {
navController.navigate(NodesRoutes.NodeDetailGraph(it)) {
@@ -84,10 +92,39 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
}
},
) {
- navController.navigate(it) { popUpTo(SettingsRoutes.Settings()) { inclusive = false } }
+ navController.navigate(it)
}
}
+ composable { backStackEntry ->
+ val parentEntry =
+ remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
+ DeviceConfigurationScreen(
+ viewModel = hiltViewModel(parentEntry),
+ onBack = navController::popBackStack,
+ onNavigate = { route -> navController.navigate(route) },
+ )
+ }
+
+ composable { backStackEntry ->
+ val parentEntry =
+ remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
+ val settingsViewModel: SettingsViewModel = hiltViewModel(parentEntry)
+ val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
+ ModuleConfigurationScreen(
+ viewModel = hiltViewModel(parentEntry),
+ excludedModulesUnlocked = excludedModulesUnlocked,
+ onBack = navController::popBackStack,
+ onNavigate = { route -> navController.navigate(route) },
+ )
+ }
+
+ composable { backStackEntry ->
+ val parentEntry =
+ remember(backStackEntry) { navController.getBackStackEntry(SettingsRoutes.SettingsGraph::class) }
+ AdministrationScreen(viewModel = hiltViewModel(parentEntry), onBack = navController::popBackStack)
+ }
+
composable(
deepLinks =
listOf(
@@ -104,6 +141,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
+ LaunchedEffect(Unit) { viewModel.setResponseStateLoading(entry) }
when (entry) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = navController::popBackStack)
@@ -133,6 +171,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) {
route = entry.route::class,
parentGraphRoute = SettingsRoutes.SettingsGraph::class,
) { viewModel ->
+ LaunchedEffect(Unit) { viewModel.setResponseStateLoading(entry) }
when (entry) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = navController::popBackStack)
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
index 7d1ebfbd5..0e7215d5c 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
@@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
@@ -35,7 +36,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
-import no.nordicsemi.android.common.core.simpleSharedFlow
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.ble.BleError
import org.meshtastic.core.ble.BluetoothRepository
@@ -82,10 +82,10 @@ constructor(
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
val connectionState: StateFlow = _connectionState.asStateFlow()
- private val _receivedData = simpleSharedFlow()
+ private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64)
val receivedData: SharedFlow = _receivedData
- private val _connectionError = simpleSharedFlow()
+ private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64)
val connectionError: SharedFlow = _connectionError.asSharedFlow()
// Thread-safe StateFlow for tracking device address changes
@@ -371,7 +371,7 @@ constructor(
serviceScope.handledLaunch { handleSendToRadio(a) }
}
- private val _meshActivity = simpleSharedFlow()
+ private val _meshActivity = MutableSharedFlow(extraBufferCapacity = 64)
val meshActivity: SharedFlow = _meshActivity.asSharedFlow()
private fun emitSendActivity() {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
index 48497a762..3b36c9e19 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
@@ -94,6 +94,10 @@ constructor(
radioConfigRepository?.channelSetFlow?.onEach { channelSet.value = it }?.launchIn(scope)
}
+ fun getCachedLocalConfig(): LocalConfig = localConfig.value
+
+ fun getCachedChannelSet(): ChannelSet = channelSet.value
+
@VisibleForTesting internal constructor() : this(null, null, null, null)
fun getCurrentPacketId(): Long = currentPacketId.get()
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index db1a6066f..2f01f3368 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -31,10 +31,8 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.runBlocking
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
@@ -249,9 +247,7 @@ class MeshService : Service() {
override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) }
- override fun getConfig(): ByteArray = toRemoteExceptions {
- runBlocking { radioConfigRepository.localConfigFlow.first().encode() }
- }
+ override fun getConfig(): ByteArray = toRemoteExceptions { commandSender.getCachedLocalConfig().encode() }
override fun setConfig(payload: ByteArray) = toRemoteExceptions {
router.actionHandler.handleSetConfig(payload, myNodeNum)
@@ -310,7 +306,7 @@ class MeshService : Service() {
}
override fun getChannelSet(): ByteArray = toRemoteExceptions {
- runBlocking { radioConfigRepository.channelSetFlow.first().encode() }
+ commandSender.getCachedChannelSet().encode()
}
override fun getNodes(): List = nodeManager.getNodes()
diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt
index 0e3982421..1ec635cc6 100644
--- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt
+++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BleConnection.kt
@@ -19,6 +19,7 @@ package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
@@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import no.nordicsemi.android.common.core.simpleSharedFlow
import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic
@@ -72,7 +74,7 @@ class BleConnection(
*
* @param p The peripheral to connect to.
*/
- suspend fun connect(p: Peripheral) {
+ suspend fun connect(p: Peripheral) = withContext(NonCancellable) {
stateJob?.cancel()
peripheral = p
@@ -156,7 +158,7 @@ class BleConnection(
}
/** Disconnects from the current peripheral. */
- suspend fun disconnect() {
+ suspend fun disconnect() = withContext(NonCancellable) {
stateJob?.cancel()
stateJob = null
peripheral?.disconnect()
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
index 730252c62..564c66515 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
@@ -16,8 +16,11 @@
*/
package org.meshtastic.core.common.util
+import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
@@ -26,15 +29,31 @@ import javax.inject.Inject
* for ensuring that only one operation of a certain type is running at a time.
*/
class SequentialJob @Inject constructor() {
- private val job = AtomicReference(null)
+ private val job = AtomicReference()
/**
* Cancels the previous job (if any) and launches a new one in the given [scope]. The new job uses [handledLaunch]
* to ensure exceptions are reported.
+ *
+ * @param timeoutMs Optional timeout in milliseconds. If > 0, the [block] is wrapped in [withTimeout] so that
+ * indefinitely-suspended coroutines (e.g. blocked DataStore reads) throw [TimeoutCancellationException] instead
+ * of hanging silently.
*/
- fun launch(scope: CoroutineScope, block: suspend CoroutineScope.() -> Unit) {
+ fun launch(scope: CoroutineScope, timeoutMs: Long = 0, block: suspend CoroutineScope.() -> Unit) {
cancel()
- val newJob = scope.handledLaunch(block = block)
+ val newJob =
+ scope.handledLaunch {
+ if (timeoutMs > 0) {
+ try {
+ withTimeout(timeoutMs, block)
+ } catch (e: TimeoutCancellationException) {
+ Logger.w { "SequentialJob timed out after ${timeoutMs}ms" }
+ throw e
+ }
+ } else {
+ block()
+ }
+ }
job.set(newJob)
newJob.invokeOnCompletion { job.compareAndSet(newJob, null) }
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt
index 622da459a..35d9c0848 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.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.data.datasource
import kotlinx.coroutines.flow.Flow
@@ -54,7 +53,8 @@ class SwitchingNodeInfoReadDataSource @Inject constructor(private val dbManager:
}
override suspend fun getNodesOlderThan(lastHeard: Int): List =
- dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) }
+ dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) } ?: emptyList()
- override suspend fun getUnknownNodes(): List = dbManager.withDb { it.nodeInfoDao().getUnknownNodes() }
+ override suspend fun getUnknownNodes(): List =
+ dbManager.withDb { it.nodeInfoDao().getUnknownNodes() } ?: emptyList()
}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt
index c201cab03..6b5501910 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt
@@ -33,33 +33,43 @@ constructor(
private val dispatchers: CoroutineDispatchers,
) : NodeInfoWriteDataSource {
- override suspend fun upsert(node: NodeEntity) =
+ override suspend fun upsert(node: NodeEntity) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } }
+ }
- override suspend fun installConfig(mi: MyNodeEntity, nodes: List) =
+ override suspend fun installConfig(mi: MyNodeEntity, nodes: List) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } }
+ }
- override suspend fun clearNodeDB(preserveFavorites: Boolean) =
+ override suspend fun clearNodeDB(preserveFavorites: Boolean) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo(preserveFavorites) } }
+ }
- override suspend fun clearMyNodeInfo() =
+ override suspend fun clearMyNodeInfo() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearMyNodeInfo() } }
+ }
- override suspend fun deleteNode(num: Int) =
+ override suspend fun deleteNode(num: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNode(num) } }
+ }
- override suspend fun deleteNodes(nodeNums: List) =
+ override suspend fun deleteNodes(nodeNums: List) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNodes(nodeNums) } }
+ }
- override suspend fun deleteMetadata(num: Int) =
+ override suspend fun deleteMetadata(num: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteMetadata(num) } }
+ }
- override suspend fun upsert(metadata: MetadataEntity) =
+ override suspend fun upsert(metadata: MetadataEntity) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(metadata) } }
+ }
- override suspend fun setNodeNotes(num: Int, notes: String) =
+ override suspend fun setNodeNotes(num: Int, notes: String) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeNotes(num, notes) } }
+ }
- override suspend fun backfillDenormalizedNames() =
+ override suspend fun backfillDenormalizedNames() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().backfillDenormalizedNames() } }
+ }
}
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index 7754211bb..3ae7d49f7 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -23,6 +23,7 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -44,6 +45,7 @@ import javax.inject.Singleton
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Singleton
@Suppress("TooManyFunctions")
+@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseManager @Inject constructor(private val app: Application, private val dispatchers: CoroutineDispatchers) {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
@@ -114,8 +116,15 @@ class DatabaseManager @Inject constructor(private val app: Application, private
Logger.i { "Switched active DB to ${anonymizeDbName(dbName)} for address ${anonymizeAddress(address)}" }
}
+ private val limitedIo = dispatchers.io.limitedParallelism(4)
+
/** Execute [block] with the current DB instance. */
- inline fun withDb(block: (MeshtasticDatabase) -> T): T = block(currentDb.value)
+ suspend fun withDb(block: suspend (MeshtasticDatabase) -> T): T? = withContext(limitedIo) {
+ val active = _currentDb.value?.openHelper?.databaseName ?: return@withContext null
+ markLastUsed(active)
+ val db = _currentDb.value ?: return@withContext null // Use the cached current DB
+ block(db)
+ }
/** Returns true if a database exists for the given device address. */
fun hasDatabaseFor(address: String?): Boolean {
diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
index 7aba5f310..660a20e4e 100644
--- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -91,6 +91,12 @@ object SettingsRoutes {
@Serializable data class Settings(val destNum: Int? = null) : Route
+ @Serializable data object DeviceConfiguration : Route
+
+ @Serializable data object ModuleConfiguration : Route
+
+ @Serializable data object Administration : Route
+
// region radio Config Routes
@Serializable data object User : Route
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index b77231ac7..11695a4c3 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -338,6 +338,7 @@
Direct Message
NodeDB reset
Delivery confirmed
+ Your device may disconnect and reboot while settings are applied.
Error
Ignore
Remove from ignored
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
index 2137061f3..77f2b49c0 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
@@ -98,7 +98,7 @@ class ServiceRepository @Inject constructor() {
}
}
- private val _meshPacketFlow = MutableSharedFlow()
+ private val _meshPacketFlow = MutableSharedFlow(extraBufferCapacity = 64)
val meshPacketFlow: SharedFlow
get() = _meshPacketFlow
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 d626be2d4..006e02fcf 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
@@ -32,7 +32,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -90,7 +89,7 @@ fun NeighborInfoLogScreen(
Scaffold(
topBar = {
- val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState()
+ val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle()
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle = 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 dcadc596d..1fdd5cf5b 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
@@ -32,7 +32,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -111,7 +110,7 @@ fun TracerouteLogScreen(
Scaffold(
topBar = {
- val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState()
+ val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle = stringResource(Res.string.traceroute_log),
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
new file mode 100644
index 000000000..1d5c16f4e
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
@@ -0,0 +1,191 @@
+/*
+ * 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.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.database.model.Node
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.administration
+import org.meshtastic.core.resources.preserve_favorites
+import org.meshtastic.core.resources.remotely_administrating
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.radio.AdminRoute
+import org.meshtastic.feature.settings.radio.ExpressiveSection
+import org.meshtastic.feature.settings.radio.RadioConfigState
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.feature.settings.radio.ResponseState
+import org.meshtastic.feature.settings.radio.component.LoadingOverlay
+import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
+import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog
+import org.meshtastic.feature.settings.radio.component.WarningDialog
+
+@Composable
+fun AdministrationScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val destNode by viewModel.destNode.collectAsStateWithLifecycle()
+ val enabled = state.connected && !state.responseState.isWaiting()
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = stringResource(Res.string.administration),
+ subtitle =
+ if (state.isLocal) {
+ destNode?.user?.long_name
+ } else {
+ val remoteName = destNode?.user?.long_name ?: ""
+ stringResource(Res.string.remotely_administrating, remoteName)
+ },
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onBack,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ExpressiveSection(
+ title = stringResource(Res.string.administration),
+ titleColor = MaterialTheme.colorScheme.error,
+ ) {
+ AdminRouteItems(viewModel = viewModel, enabled = enabled, state = state, destNode = destNode)
+ }
+ }
+ }
+
+ LoadingOverlay(state = state.responseState)
+
+ if (state.responseState is ResponseState.Success || state.responseState is ResponseState.Error) {
+ PacketResponseStateDialog(
+ state = state.responseState,
+ onDismiss = { viewModel.clearPacketResponse() },
+ onComplete = {
+ viewModel.clearPacketResponse()
+ onBack()
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun AdminRouteItems(
+ viewModel: RadioConfigViewModel,
+ enabled: Boolean,
+ state: RadioConfigState,
+ destNode: Node?,
+) {
+ AdminRoute.entries.forEach { route ->
+ var showDialog by remember { mutableStateOf(false) }
+ if (showDialog) {
+ AdminActionDialog(
+ route = route,
+ destNode = destNode,
+ enabled = enabled,
+ state = state,
+ onDismiss = { showDialog = false },
+ onConfirm = { viewModel.setResponseStateLoading(route) },
+ onPreserveFavoritesChange = { viewModel.setPreserveFavorites(it) },
+ )
+ }
+
+ ListItem(
+ enabled = enabled,
+ text = stringResource(route.title),
+ leadingIcon = route.icon,
+ leadingIconTint = MaterialTheme.colorScheme.error,
+ textColor = MaterialTheme.colorScheme.error,
+ trailingIcon = null,
+ ) {
+ showDialog = true
+ }
+ }
+}
+
+@Composable
+private fun AdminActionDialog(
+ route: AdminRoute,
+ destNode: Node?,
+ enabled: Boolean,
+ state: RadioConfigState,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit,
+ onPreserveFavoritesChange: (Boolean) -> Unit,
+) {
+ if (route == AdminRoute.SHUTDOWN || route == AdminRoute.REBOOT) {
+ ShutdownConfirmationDialog(
+ title = "${stringResource(route.title)}?",
+ node = destNode,
+ onDismiss = onDismiss,
+ isShutdown = route == AdminRoute.SHUTDOWN,
+ onConfirm = onConfirm,
+ )
+ } else {
+ WarningDialog(
+ title = "${stringResource(route.title)}?",
+ text = {
+ if (route == AdminRoute.NODEDB_RESET) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(text = stringResource(Res.string.preserve_favorites))
+ Switch(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ enabled = enabled,
+ checked = state.nodeDbResetPreserveFavorites,
+ onCheckedChange = onPreserveFavoritesChange,
+ )
+ }
+ }
+ },
+ onDismiss = onDismiss,
+ onConfirm = onConfirm,
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
new file mode 100644
index 000000000..77dc42419
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+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.navigation.Route
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.device_configuration
+import org.meshtastic.core.resources.remotely_administrating
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.navigation.ConfigRoute
+import org.meshtastic.feature.settings.radio.ExpressiveSection
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+
+@Composable
+fun DeviceConfigurationScreen(
+ viewModel: RadioConfigViewModel = hiltViewModel(),
+ onBack: () -> Unit,
+ onNavigate: (Route) -> Unit,
+) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val destNode by viewModel.destNode.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = stringResource(Res.string.device_configuration),
+ subtitle =
+ if (state.isLocal) {
+ destNode?.user?.long_name
+ } else {
+ val remoteName = destNode?.user?.long_name ?: ""
+ stringResource(Res.string.remotely_administrating, remoteName)
+ },
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onBack,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
+ ConfigRoute.deviceConfigRoutes(state.metadata).forEach {
+ ListItem(
+ text = stringResource(it.title),
+ leadingIcon = it.icon,
+ enabled = state.connected && !state.responseState.isWaiting(),
+ ) {
+ onNavigate(it.route)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
new file mode 100644
index 000000000..630d19c0b
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.feature.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+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.navigation.Route
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.module_settings
+import org.meshtastic.core.resources.remotely_administrating
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.navigation.ModuleRoute
+import org.meshtastic.feature.settings.radio.ExpressiveSection
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+
+@Composable
+fun ModuleConfigurationScreen(
+ viewModel: RadioConfigViewModel = hiltViewModel(),
+ excludedModulesUnlocked: Boolean = false,
+ onBack: () -> Unit,
+ onNavigate: (Route) -> Unit,
+) {
+ val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
+ val destNode by viewModel.destNode.collectAsStateWithLifecycle()
+
+ val modules =
+ remember(state.metadata, excludedModulesUnlocked) {
+ if (excludedModulesUnlocked) {
+ ModuleRoute.entries
+ } else {
+ ModuleRoute.filterExcludedFrom(state.metadata, state.userConfig.role)
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = stringResource(Res.string.module_settings),
+ subtitle =
+ if (state.isLocal) {
+ destNode?.user?.long_name
+ } else {
+ val remoteName = destNode?.user?.long_name ?: ""
+ stringResource(Res.string.remotely_administrating, remoteName)
+ },
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onBack,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ ExpressiveSection(title = stringResource(Res.string.module_settings)) {
+ modules.forEach {
+ ListItem(
+ text = stringResource(it.title),
+ leadingIcon = it.icon,
+ enabled = state.connected && !state.responseState.isWaiting(),
+ ) {
+ onNavigate(it.route)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index 1887edbb3..bd5ebc655 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -28,6 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@@ -59,7 +60,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.os.ConfigurationCompat
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
@@ -106,14 +106,14 @@ import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchListItem
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.showToast
-import org.meshtastic.feature.settings.navigation.getNavRouteFrom
+import org.meshtastic.feature.settings.navigation.ConfigRoute
+import org.meshtastic.feature.settings.navigation.ModuleRoute
+import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigItemList
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
-import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.feature.settings.util.LanguageUtils
import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
import org.meshtastic.proto.DeviceProfile
@@ -125,8 +125,8 @@ import kotlin.time.Duration.Companion.seconds
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun SettingsScreen(
- settingsViewModel: SettingsViewModel = hiltViewModel(),
- viewModel: RadioConfigViewModel = hiltViewModel(),
+ settingsViewModel: SettingsViewModel,
+ viewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit = {},
onNavigate: (Route) -> Unit = {},
) {
@@ -137,23 +137,6 @@ fun SettingsScreen(
val isOtaCapable by settingsViewModel.isOtaCapable.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
- var isWaiting by remember { mutableStateOf(false) }
- if (isWaiting) {
- PacketResponseStateDialog(
- state = state.responseState,
- onDismiss = {
- isWaiting = false
- viewModel.clearPacketResponse()
- },
- onComplete = {
- getNavRouteFrom(state.route)?.let { route ->
- isWaiting = false
- viewModel.clearPacketResponse()
- onNavigate(route)
- }
- },
- )
- }
var deviceProfile by remember { mutableStateOf(null) }
var showEditDeviceProfileDialog by remember { mutableStateOf(false) }
@@ -241,17 +224,22 @@ fun SettingsScreen(
)
},
) { paddingValues ->
- Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp)) {
+ Column(
+ modifier = Modifier.verticalScroll(rememberScrollState()).padding(paddingValues).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
RadioConfigItemList(
state = state,
isManaged = localConfig.security?.is_managed ?: false,
- node = destNode,
- excludedModulesUnlocked = excludedModulesUnlocked,
isOtaCapable = isOtaCapable,
- onPreserveFavoritesToggle = { viewModel.setPreserveFavorites(it) },
onRouteClick = { route ->
- isWaiting = true
- viewModel.setResponseStateLoading(route)
+ val navRoute =
+ when (route) {
+ is ConfigRoute -> route.route
+ is ModuleRoute -> route.route
+ else -> null
+ }
+ navRoute?.let { onNavigate(it) }
},
onImport = {
viewModel.clearPacketResponse()
@@ -273,7 +261,7 @@ fun SettingsScreen(
val context = LocalContext.current
- TitledCard(title = stringResource(Res.string.app_settings), modifier = Modifier.padding(top = 16.dp)) {
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
if (state.analyticsAvailable) {
val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
SwitchListItem(
@@ -434,7 +422,7 @@ fun SettingsScreen(
ListItem(
text = stringResource(Res.string.acknowledgements),
leadingIcon = Icons.Rounded.Info,
- trailingIcon = null,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
onNavigate(SettingsRoutes.About)
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt
index 9a2c1f0ee..0c8737e52 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsScreen.kt
@@ -37,7 +37,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -47,6 +46,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
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.resources.Res
import org.meshtastic.core.resources.add
@@ -64,8 +64,8 @@ import org.meshtastic.core.ui.component.MainAppBar
@Composable
fun FilterSettingsScreen(viewModel: FilterSettingsViewModel = hiltViewModel(), onBack: () -> Unit) {
- val filterEnabled by viewModel.filterEnabled.collectAsState()
- val filterWords by viewModel.filterWords.collectAsState()
+ val filterEnabled by viewModel.filterEnabled.collectAsStateWithLifecycle()
+ val filterWords by viewModel.filterWords.collectAsStateWithLifecycle()
var newWord by remember { mutableStateOf("") }
Scaffold(
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt
index 1821fd6c3..9c6bb2cc8 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt
@@ -93,7 +93,7 @@ enum class ConfigRoute(val title: StringResource, val route: Route, val icon: Im
}
}
- val radioConfigRoutes = listOf(LORA, CHANNELS, SECURITY)
+ val radioConfigRoutes = listOf(USER, LORA, CHANNELS, SECURITY)
fun deviceConfigRoutes(metadata: DeviceMetadata?): List =
filterExcludedFrom(metadata) - radioConfigRoutes
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
index 06c5a853b..51ca46704 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
@@ -33,12 +33,12 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.database.entity.NodeEntity
import org.meshtastic.core.resources.Res
@@ -56,9 +56,9 @@ import org.meshtastic.core.ui.component.NodeChip
*/
@Composable
fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewModel()) {
- val olderThanDays by viewModel.olderThanDays.collectAsState()
- val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsState()
- val nodesToDelete by viewModel.nodesToDelete.collectAsState()
+ val olderThanDays by viewModel.olderThanDays.collectAsStateWithLifecycle()
+ val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsStateWithLifecycle()
+ val nodesToDelete by viewModel.nodesToDelete.collectAsStateWithLifecycle()
LaunchedEffect(olderThanDays, onlyUnknownNodes) { viewModel.getNodesToDelete() }
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
index d84cad310..b87987539 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
@@ -18,37 +18,37 @@ package org.meshtastic.feature.settings.radio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.rounded.AdminPanelSettings
+import androidx.compose.material.icons.rounded.AppSettingsAlt
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.CleaningServices
import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material.icons.rounded.RestartAlt
import androidx.compose.material.icons.rounded.Restore
+import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.SystemUpdate
import androidx.compose.material.icons.rounded.Upload
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
@@ -66,18 +66,13 @@ import org.meshtastic.core.resources.import_configuration
import org.meshtastic.core.resources.message_device_managed
import org.meshtastic.core.resources.module_settings
import org.meshtastic.core.resources.nodedb_reset
-import org.meshtastic.core.resources.preserve_favorites
import org.meshtastic.core.resources.radio_configuration
import org.meshtastic.core.resources.reboot
import org.meshtastic.core.resources.shutdown
import org.meshtastic.core.ui.component.ListItem
-import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.settings.navigation.ConfigRoute
-import org.meshtastic.feature.settings.navigation.ModuleRoute
-import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog
-import org.meshtastic.feature.settings.radio.component.WarningDialog
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@@ -85,30 +80,16 @@ import org.meshtastic.feature.settings.radio.component.WarningDialog
fun RadioConfigItemList(
state: RadioConfigState,
isManaged: Boolean,
- node: Node? = null,
- excludedModulesUnlocked: Boolean = false,
isOtaCapable: Boolean = false,
- onPreserveFavoritesToggle: (Boolean) -> Unit = {},
onRouteClick: (Enum<*>) -> Unit = {},
onImport: () -> Unit = {},
onExport: () -> Unit = {},
onNavigate: (Route) -> Unit,
) {
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
- var modules by remember {
- mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role))
- }
- LaunchedEffect(excludedModulesUnlocked, state.metadata, state.radioConfig.device?.role) {
- if (excludedModulesUnlocked) {
- modules = ModuleRoute.entries
- } else {
- modules = ModuleRoute.filterExcludedFrom(state.metadata, state.radioConfig.device?.role)
- }
- }
-
- Column {
- TitledCard(title = stringResource(Res.string.radio_configuration)) {
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
if (isManaged) {
ManagedMessage()
}
@@ -117,126 +98,122 @@ fun RadioConfigItemList(
}
}
- TitledCard(title = stringResource(Res.string.device_configuration), modifier = Modifier.padding(top = 16.dp)) {
+ ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
if (isManaged) {
ManagedMessage()
}
- ConfigRoute.deviceConfigRoutes(state.metadata).forEach {
- ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
- }
- }
-
- TitledCard(title = stringResource(Res.string.module_settings), modifier = Modifier.padding(top = 16.dp)) {
- if (isManaged) {
- ManagedMessage()
- }
-
- modules.forEach {
- ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
- }
- }
- }
-
- if (state.isLocal) {
- TitledCard(title = stringResource(Res.string.backup_restore), modifier = Modifier.padding(top = 16.dp)) {
- if (isManaged) {
- ManagedMessage()
- }
-
ListItem(
- text = stringResource(Res.string.import_configuration),
- leadingIcon = Icons.Rounded.Download,
+ text = stringResource(Res.string.device_configuration),
+ leadingIcon = Icons.Rounded.AppSettingsAlt,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
enabled = enabled,
- onClick = onImport,
- )
- ListItem(
- text = stringResource(Res.string.export_configuration),
- leadingIcon = Icons.Rounded.Upload,
- enabled = enabled,
- onClick = onExport,
- )
- }
- }
-
- TitledCard(title = stringResource(Res.string.administration), modifier = Modifier.padding(top = 16.dp)) {
- AdminRoute.entries.forEach { route ->
- var showDialog by remember { mutableStateOf(false) }
- if (showDialog) {
- // Use enhanced confirmation for SHUTDOWN and REBOOT
- if (route == AdminRoute.SHUTDOWN || route == AdminRoute.REBOOT) {
- ShutdownConfirmationDialog(
- title = "${stringResource(route.title)}?",
- node = node,
- onDismiss = { showDialog = false },
- isShutdown = route == AdminRoute.SHUTDOWN,
- onConfirm = { onRouteClick(route) },
- )
- } else {
- WarningDialog(
- title = "${stringResource(route.title)}?",
- text = {
- if (route == AdminRoute.NODEDB_RESET) {
- Row(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(text = stringResource(Res.string.preserve_favorites))
- Switch(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- enabled = enabled,
- checked = state.nodeDbResetPreserveFavorites,
- onCheckedChange = onPreserveFavoritesToggle,
- )
- }
- }
- },
- onDismiss = { showDialog = false },
- onConfirm = { onRouteClick(route) },
- )
- }
- }
-
- ListItem(
- enabled = enabled,
- text = stringResource(route.title),
- leadingIcon = route.icon,
- trailingIcon = null,
) {
- showDialog = true
+ onNavigate(SettingsRoutes.DeviceConfiguration)
}
}
- }
- if (state.isLocal) {
- TitledCard(title = stringResource(Res.string.advanced_title), modifier = Modifier.padding(top = 16.dp)) {
+ ExpressiveSection(title = stringResource(Res.string.module_settings)) {
if (isManaged) {
ManagedMessage()
}
+ ListItem(
+ text = stringResource(Res.string.module_settings),
+ leadingIcon = Icons.Rounded.Settings,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.ModuleConfiguration)
+ }
+ }
+
+ if (state.isLocal) {
+ ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
- if (isOtaCapable) {
ListItem(
- text = stringResource(Res.string.firmware_update_title),
- leadingIcon = Icons.Rounded.SystemUpdate,
+ text = stringResource(Res.string.import_configuration),
+ leadingIcon = Icons.Rounded.Download,
enabled = enabled,
- onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
+ onClick = onImport,
+ )
+ ListItem(
+ text = stringResource(Res.string.export_configuration),
+ leadingIcon = Icons.Rounded.Upload,
+ enabled = enabled,
+ onClick = onExport,
)
}
-
- ListItem(
- text = stringResource(Res.string.clean_node_database_title),
- leadingIcon = Icons.Rounded.CleaningServices,
- enabled = enabled,
- onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
- )
-
- ListItem(
- text = stringResource(Res.string.debug_panel),
- leadingIcon = Icons.Rounded.BugReport,
- enabled = enabled,
- onClick = { onNavigate(SettingsRoutes.DebugPanel) },
- )
}
+
+ ExpressiveSection(title = stringResource(Res.string.administration)) {
+ ListItem(
+ text = stringResource(Res.string.administration),
+ leadingIcon = Icons.Rounded.AdminPanelSettings,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ leadingIconTint = MaterialTheme.colorScheme.error,
+ textColor = MaterialTheme.colorScheme.error,
+ trailingIconTint = MaterialTheme.colorScheme.error,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.Administration)
+ }
+ }
+
+ if (state.isLocal) {
+ ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+
+ if (isOtaCapable) {
+ ListItem(
+ text = stringResource(Res.string.firmware_update_title),
+ leadingIcon = Icons.Rounded.SystemUpdate,
+ enabled = enabled,
+ onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
+ )
+ }
+
+ ListItem(
+ text = stringResource(Res.string.clean_node_database_title),
+ leadingIcon = Icons.Rounded.CleaningServices,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
+ )
+
+ ListItem(
+ text = stringResource(Res.string.debug_panel),
+ leadingIcon = Icons.Rounded.BugReport,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.DebugPanel) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ExpressiveSection(
+ title: String,
+ modifier: Modifier = Modifier,
+ titleColor: Color = MaterialTheme.colorScheme.primary,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = titleColor,
+ )
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
+ content = content,
+ )
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index ec9d29c5c..2cb947c8f 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -181,6 +181,12 @@ constructor(
.onEach { lc -> if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(radioConfig = lc) } }
.launchIn(viewModelScope)
+ radioConfigRepository.channelSetFlow
+ .onEach { cs ->
+ if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(channelList = cs.settings) }
+ }
+ .launchIn(viewModelScope)
+
radioConfigRepository.moduleConfigFlow
.onEach { lmc ->
if (radioConfigState.value.isLocal) _radioConfigState.update { it.copy(moduleConfig = lmc) }
@@ -608,16 +614,7 @@ constructor(
fun setResponseStateLoading(route: Enum<*>) {
val destNum = destNode.value?.num ?: return
- _radioConfigState.update {
- RadioConfigState(
- isLocal = it.isLocal,
- connected = it.connected,
- route = route.name,
- metadata = it.metadata,
- nodeDbResetPreserveFavorites = it.nodeDbResetPreserveFavorites,
- responseState = ResponseState.Loading(),
- )
- }
+ _radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) }
when (route) {
ConfigRoute.USER -> getOwner(destNum)
@@ -862,6 +859,14 @@ constructor(
sendAdminRequest(destNum)
}
requestIds.update { it.apply { remove(data.request_id) } }
+
+ if (requestIds.value.isEmpty()) {
+ if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
+ clearPacketResponse()
+ } else if (route.isEmpty()) {
+ setResponseStateSuccess()
+ }
+ }
}
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
index 5915c54aa..30c5c8214 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
@@ -67,11 +67,13 @@ import org.meshtastic.core.ui.component.dragContainer
import org.meshtastic.core.ui.component.dragDropItemsIndexed
import org.meshtastic.core.ui.component.rememberDragDropState
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
+import org.meshtastic.feature.settings.radio.ResponseState
import org.meshtastic.feature.settings.radio.channel.component.ChannelCard
import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHeader
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog
import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog
+import org.meshtastic.feature.settings.radio.component.LoadingOverlay
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
@@ -80,20 +82,24 @@ import org.meshtastic.proto.Config
fun ChannelConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
- if (state.responseState.isWaiting()) {
- PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
- }
+ Box(modifier = Modifier.fillMaxSize()) {
+ ChannelConfigScreen(
+ title = stringResource(Res.string.channels),
+ onBack = onBack,
+ settingsList = state.channelList,
+ loraConfig = state.radioConfig.lora ?: Config.LoRaConfig(),
+ maxChannels = viewModel.maxChannels,
+ firmwareVersion = state.metadata?.firmware_version ?: "0.0.0",
+ enabled = state.connected,
+ onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) },
+ )
- ChannelConfigScreen(
- title = stringResource(Res.string.channels),
- onBack = onBack,
- settingsList = state.channelList,
- loraConfig = state.radioConfig.lora ?: Config.LoRaConfig(),
- maxChannels = viewModel.maxChannels,
- firmwareVersion = state.metadata?.firmware_version ?: "0.0.0",
- enabled = state.connected,
- onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) },
- )
+ LoadingOverlay(state = state.responseState)
+
+ if (state.responseState is ResponseState.Success || state.responseState is ResponseState.Error) {
+ PacketResponseStateDialog(state = state.responseState, onDismiss = viewModel::clearPacketResponse)
+ }
+ }
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt
new file mode 100644
index 000000000..18ade8df5
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.feature.settings.radio.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.meshtastic.feature.settings.radio.ResponseState
+
+private const val LOADING_OVERLAY_ALPHA = 0.8f
+private const val PERCENTAGE_FACTOR = 100
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) {
+ AnimatedVisibility(visible = state is ResponseState.Loading, enter = fadeIn(), exit = fadeOut()) {
+ Box(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = LOADING_OVERLAY_ALPHA))
+ .clickable(enabled = false) {},
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier = Modifier.padding(32.dp).fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ if (state is ResponseState.Loading) {
+ val progress by
+ animateFloatAsState(
+ targetValue = state.completed.toFloat() / state.total.toFloat(),
+ label = "loading_progress",
+ )
+
+ Box(contentAlignment = Alignment.Center) {
+ CircularWavyProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.size(80.dp),
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ Text(
+ text = "%.0f%%".format(progress * PERCENTAGE_FACTOR),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ state.status?.let { status ->
+ Text(
+ text = status,
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt
index 366f8669c..1f7e42681 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt
@@ -18,10 +18,17 @@ package org.meshtastic.feature.settings.radio.component
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -29,19 +36,23 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delivery_confirmed
+import org.meshtastic.core.resources.delivery_confirmed_reboot_warning
import org.meshtastic.core.resources.error
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.settings.radio.ResponseState
private const val AUTO_DISMISS_DELAY_MS = 1500L
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PacketResponseStateDialog(state: ResponseState, onDismiss: () -> Unit = {}, onComplete: () -> Unit = {}) {
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
@@ -49,54 +60,139 @@ fun PacketResponseStateDialog(state: ResponseState, onDismiss: () -> Unit
if (state is ResponseState.Success) {
delay(AUTO_DISMISS_DELAY_MS)
onDismiss()
+ backDispatcher?.onBackPressed()
}
}
MeshtasticDialog(
- onDismiss = onDismiss,
- title = "", // Title is handled in the text block for more control
+ onDismiss = if (state is ResponseState.Loading) onDismiss else null,
+ title = null,
+ icon = null,
text = {
- Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
- if (state is ResponseState.Loading) {
- val progress by
- animateFloatAsState(
- targetValue = state.completed.toFloat() / state.total.toFloat(),
- label = "progress",
- )
- Text("%.0f%%".format(progress * 100))
- LinearProgressIndicator(progress = progress, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
- state.status?.let {
- Text(
- text = it,
- modifier = Modifier.padding(top = 8.dp),
- style = MaterialTheme.typography.bodySmall,
- )
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ when (state) {
+ is ResponseState.Loading -> {
+ LoadingContent(state = state, onComplete = onComplete)
}
- if (state.completed >= state.total) onComplete()
- }
- if (state is ResponseState.Success) {
- Text(text = stringResource(Res.string.delivery_confirmed))
- }
- if (state is ResponseState.Error) {
- Text(text = stringResource(Res.string.error), minLines = 2)
- Text(text = state.error.asString())
+ is ResponseState.Success -> {
+ SuccessContent()
+ }
+ is ResponseState.Error -> {
+ ErrorContent(state = state)
+ }
+ ResponseState.Empty -> {}
}
}
},
dismissable = false,
- onConfirm = {
- onDismiss()
- if (state is ResponseState.Success || state is ResponseState.Error) {
+ onConfirm =
+ if (state !is ResponseState.Loading) {
+ {
+ onDismiss()
backDispatcher?.onBackPressed()
}
+ } else {
+ null
},
confirmText = stringResource(Res.string.close),
- dismissText = null, // Hide dismiss button, only show "Close" confirm button
+ dismissText = if (state is ResponseState.Loading) stringResource(Res.string.cancel) else null,
)
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit) {
+ val progress by
+ animateFloatAsState(targetValue = state.completed.toFloat() / state.total.toFloat(), label = "progress")
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "%.0f%%".format(progress * 100),
+ style = MaterialTheme.typography.displaySmall,
+ color = MaterialTheme.colorScheme.secondary,
+ )
+ LinearWavyProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ state.status?.let {
+ Text(
+ text = it,
+ modifier = Modifier.padding(top = 16.dp),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ if (state.completed >= state.total) onComplete()
+}
+
+@Composable
+private fun SuccessContent() {
+ Icon(
+ imageVector = Icons.Filled.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(84.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(Res.string.delivery_confirmed),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ text = stringResource(Res.string.delivery_confirmed_reboot_warning),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
+@Composable
+private fun ErrorContent(state: ResponseState.Error) {
+ Icon(
+ imageVector = Icons.Filled.Error,
+ contentDescription = null,
+ modifier = Modifier.size(84.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(Res.string.error),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.error,
+ )
+ Text(
+ text = "${state.error.asString()}.",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
@Preview(showBackground = true)
@Composable
-private fun PacketResponseStateDialogPreview() {
+private fun PacketResponseStateDialogLoadingPreview() {
PacketResponseStateDialog(state = ResponseState.Loading(total = 17, completed = 5))
}
+
+@Preview(showBackground = true)
+@Composable
+private fun PacketResponseStateDialogSuccessPreview() {
+ PacketResponseStateDialog(state = ResponseState.Success(Unit))
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PacketResponseStateDialogErrorPreview() {
+ PacketResponseStateDialog(
+ state = ResponseState.Error(org.meshtastic.core.resources.UiText.DynamicString("Failed to send packet")),
+ )
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
index 71b5ffb41..15396a60b 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt
@@ -22,6 +22,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -58,55 +59,58 @@ fun > RadioConfigScreenList(
) {
val focusManager = LocalFocusManager.current
- if (responseState.isWaiting()) {
- PacketResponseStateDialog(state = responseState, onDismiss = onDismissPacketResponse)
- }
+ Box(modifier = modifier) {
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = title,
+ canNavigateUp = true,
+ onNavigateUp = onBack,
+ ourNode = null,
+ showNodeChip = false,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { innerPadding ->
+ val showFooterButtons = configState.isDirty || additionalDirtyCheck()
- Scaffold(
- modifier = modifier,
- topBar = {
- MainAppBar(
- title = title,
- canNavigateUp = true,
- onNavigateUp = onBack,
- ourNode = null,
- showNodeChip = false,
- actions = {},
- onClickChip = {},
- )
- },
- ) { innerPadding ->
- val showFooterButtons = configState.isDirty || additionalDirtyCheck()
+ LazyColumn(
+ modifier = Modifier.padding(innerPadding).fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ content()
- LazyColumn(
- modifier = Modifier.padding(innerPadding).fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp),
- ) {
- content()
-
- item {
- AnimatedVisibility(
- visible = showFooterButtons,
- enter = fadeIn() + expandIn(),
- exit = fadeOut() + shrinkOut(),
- ) {
- PreferenceFooter(
- enabled = enabled && showFooterButtons,
- negativeText = stringResource(Res.string.discard_changes),
- onNegativeClicked = {
- focusManager.clearFocus()
- configState.reset()
- onDiscard()
- },
- positiveText = stringResource(Res.string.save_changes),
- onPositiveClicked = {
- focusManager.clearFocus()
- onSave(configState.value)
- },
- )
+ item {
+ AnimatedVisibility(
+ visible = showFooterButtons,
+ enter = fadeIn() + expandIn(),
+ exit = fadeOut() + shrinkOut(),
+ ) {
+ PreferenceFooter(
+ enabled = enabled && showFooterButtons,
+ negativeText = stringResource(Res.string.discard_changes),
+ onNegativeClicked = {
+ focusManager.clearFocus()
+ configState.reset()
+ onDiscard()
+ },
+ positiveText = stringResource(Res.string.save_changes),
+ onPositiveClicked = {
+ focusManager.clearFocus()
+ onSave(configState.value)
+ },
+ )
+ }
}
}
}
+
+ LoadingOverlay(state = responseState)
+
+ if (responseState is ResponseState.Success || responseState is ResponseState.Error) {
+ PacketResponseStateDialog(state = responseState, onDismiss = onDismissPacketResponse)
+ }
}
}
From 362ab6357c0f94811a17c8d3e6a6b2c7e1cb51ae Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 2 Mar 2026 08:52:29 -0600
Subject: [PATCH 040/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4672)
---
app/src/main/assets/device_hardware.json | 42 -----
app/src/main/assets/firmware_releases.json | 6 +
.../composeResources/values-be/strings.xml | 3 +
.../composeResources/values-bg/strings.xml | 4 +
.../composeResources/values-cs/strings.xml | 3 +
.../composeResources/values-de/strings.xml | 6 +
.../composeResources/values-el/strings.xml | 3 +
.../composeResources/values-es/strings.xml | 3 +
.../composeResources/values-et/strings.xml | 57 +++++++
.../composeResources/values-fi/strings.xml | 57 +++++++
.../composeResources/values-fr/strings.xml | 4 +
.../composeResources/values-hr/strings.xml | 1 +
.../composeResources/values-hu/strings.xml | 3 +
.../composeResources/values-it/strings.xml | 4 +
.../composeResources/values-ja/strings.xml | 160 ++++++++++++++++++
.../composeResources/values-ko/strings.xml | 3 +
.../composeResources/values-lt/strings.xml | 1 +
.../composeResources/values-nl/strings.xml | 3 +
.../composeResources/values-pl/strings.xml | 4 +
.../values-pt-rBR/strings.xml | 3 +
.../composeResources/values-pt/strings.xml | 3 +
.../composeResources/values-ro/strings.xml | 3 +
.../composeResources/values-ru/strings.xml | 50 ++++++
.../composeResources/values-sk/strings.xml | 3 +
.../composeResources/values-sv/strings.xml | 4 +
.../composeResources/values-tr/strings.xml | 3 +
.../composeResources/values-uk/strings.xml | 3 +
.../values-zh-rCN/strings.xml | 4 +
.../values-zh-rTW/strings.xml | 46 ++++-
29 files changed, 446 insertions(+), 43 deletions(-)
diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json
index 71143aa72..0699ff16b 100644
--- a/app/src/main/assets/device_hardware.json
+++ b/app/src/main/assets/device_hardware.json
@@ -1349,47 +1349,5 @@
"images": [
"tbeam-1w.svg"
]
- },
- {
- "hwModel": 123,
- "hwModelSlug": "T5_S3_EPAPER_PRO",
- "platformioTarget": "t5-s3-epaper-pro",
- "architecture": "esp32-s3",
- "activelySupported": true,
- "supportLevel": 1,
- "displayName": "LilyGo T5 S3 ePaper Pro",
- "tags": [
- "LilyGo"
- ],
- "hasMui": true,
- "partitionScheme": "8MB"
- },
- {
- "hwModel": 124,
- "hwModelSlug": "TBEAM_BPF",
- "platformioTarget": "tbeam-bpf",
- "architecture": "esp32-s3",
- "activelySupported": true,
- "supportLevel": 1,
- "displayName": "LilyGo T-Beam BPF",
- "tags": [
- "LilyGo"
- ],
- "hasMui": false,
- "partitionScheme": "8MB"
- },
- {
- "hwModel": 125,
- "hwModelSlug": "MINI_EPAPER_S3",
- "platformioTarget": "mini-epaper-s3",
- "architecture": "esp32-s3",
- "activelySupported": true,
- "supportLevel": 1,
- "displayName": "LilyGo T-Mini E-paper S3 Kit",
- "tags": [
- "LilyGo"
- ],
- "hasMui": true,
- "partitionScheme": "8MB"
}
]
\ No newline at end of file
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 01aeacbf8..dcb81f56d 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -199,6 +199,12 @@
"title": "Add VL53L0 distance sensor.",
"page_url": "https://github.com/meshtastic/firmware/pull/9706",
"zip_url": "https://discord.com/invite/meshtastic"
+ },
+ {
+ "id": "9675",
+ "title": "add FromRadioSync BLE characteristic",
+ "page_url": "https://github.com/meshtastic/firmware/pull/9675",
+ "zip_url": "https://discord.com/invite/meshtastic"
}
]
}
\ No newline at end of file
diff --git a/core/resources/src/commonMain/composeResources/values-be/strings.xml b/core/resources/src/commonMain/composeResources/values-be/strings.xml
index 9690b226f..7cfe00f42 100644
--- a/core/resources/src/commonMain/composeResources/values-be/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-be/strings.xml
@@ -245,4 +245,7 @@
Усе
Bluetooth
+ Чырвоны
+ Сіні
+ Зялёны
diff --git a/core/resources/src/commonMain/composeResources/values-bg/strings.xml b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
index 1444d7c83..7954340a3 100644
--- a/core/resources/src/commonMain/composeResources/values-bg/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-bg/strings.xml
@@ -865,4 +865,8 @@
Bluetooth
Конфигурация
+ Червен
+ Син
+ Зелен
+ Модулът е активиран
diff --git a/core/resources/src/commonMain/composeResources/values-cs/strings.xml b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
index cebaeb12c..1d170a23b 100644
--- a/core/resources/src/commonMain/composeResources/values-cs/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-cs/strings.xml
@@ -962,4 +962,7 @@
Vše
Bluetooth
+ Červená
+ Modrá
+ Zelená
diff --git a/core/resources/src/commonMain/composeResources/values-de/strings.xml b/core/resources/src/commonMain/composeResources/values-de/strings.xml
index e7c820b54..a3f76112b 100644
--- a/core/resources/src/commonMain/composeResources/values-de/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-de/strings.xml
@@ -1168,4 +1168,10 @@
Ungültiger Name, URL oder lokale URI für benutzerdefinierten Kachelanbieter.
Ein benutzerdefinierter Kachelanbieter mit diesem Namen existiert bereits.
Fehler beim Kopieren der MB Kacheldatei in den internen Speicher.
+ Unspecified
+ Rot
+ Blau
+ Grün
+ Unspecified
+ Modul aktiviert
diff --git a/core/resources/src/commonMain/composeResources/values-el/strings.xml b/core/resources/src/commonMain/composeResources/values-el/strings.xml
index 6b59874e3..25abf61a7 100644
--- a/core/resources/src/commonMain/composeResources/values-el/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-el/strings.xml
@@ -207,4 +207,7 @@
Bluetooth
+ Κόκκινο
+ Μπλε
+ Πράσινο
diff --git a/core/resources/src/commonMain/composeResources/values-es/strings.xml b/core/resources/src/commonMain/composeResources/values-es/strings.xml
index 56e33a948..8ddff0aaf 100644
--- a/core/resources/src/commonMain/composeResources/values-es/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-es/strings.xml
@@ -911,4 +911,7 @@ Estos datos de ubicación pueden ser utilizados para fines como aparecer en un m
Bluetooth
Configuración
+ Rojo
+ Azul
+ Verde
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index ef364a6b2..e07369ea9 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -933,7 +933,9 @@
Maastik
Hübriid
Halda kaardikihte
+ Kaardikihid toetavad .kml, .kmz või GeoJSON vorminguid.
Kaardikihid
+ Kaardikihte pole laetud.
Lisa kiht
Peida kiht
Näita kiht
@@ -942,6 +944,10 @@
Sõlmed siin asukohas
Vali kaardi tüüp
Halda kohandatud kardikihti
+ Lisa võrgupaani allikas
+ Kohandatud paanide allikaid ei leitud.
+ Muuda võrgupaani allikat
+ Kustuta võrgupaani allikas
Nimi ei tohi olla tühi.
Teenusepakkuja nimi on olemas.
URL ei tohi olla tühi.
@@ -1155,4 +1161,55 @@
Värskenda
Uuendatud
+ Lisa kaardikiht
+ Värskenda kihti
+ Kohalik MB-paani fail
+ Lisa kohalik MB-paani fail
+ Kohandatud paanipakkuja nimi, URL-i mall või kohalik URL on sobimatu.
+ Selle nimega kohandatud paanipakkuja on juba olemas.
+ MB-paanifaili kopeerimine sisemällu ebaõnnestus.
+ TAK (ATAK)
+ TAK-i sätted
+ Meeskonna värv
+ Liikme roll
+ Määramata
+ Valge
+ Kollane
+ Oranž
+ Fukspunane
+ Punane
+ Kastanpruun
+ Lilla
+ Tume sinine
+ Sinine
+ Tsüaan
+ Sinakasroheline
+ Roheline
+ Tume roheline
+ Pruun
+ Määramata
+ Meeskonnaliige
+ Meeskonna ülem
+ Peakorter
+ Snaiper
+ Meedik
+ Luure
+ Sidemees
+ Koer (K9)
+ Liikluskorraldus
+ Läbilaskepunkt
+ Moodul lubatud
+ Positsioonide dubleerimine
+ Positsiooni täpsus (bittides)
+ Minimaalne positsiooniintervall (sekundites)
+ Sõlmeinfo otsevastus
+ Otsevastuse hüpete maksimaalne arv
+ Saatekiiruse piiramine
+ Kiiruse piirangu aken (sekundites)
+ Max pakettide arv aknas
+ Tundmatute paketide hülgamine
+ Tundmatu pakettide lävi
+ Ainult kohalik telemeetria (vahendajad)
+ Ainult kohalik asukoht (vahendajad)
+ Säilita ruuteri hüpped
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 52c37cba5..1883a6d50 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -933,7 +933,9 @@
Maasto
Hybridi
Hallitse Karttatasoja
+ Karttatasot tukevat .kml-, .kmz- tai GeoJSON-tiedostomuotoja.
Karttatasot
+ Karttatasoja ei ole ladattu.
Lisää taso
Piilota taso
Näytä taso
@@ -942,6 +944,10 @@
Laitteet tässä sijainnissa
Valittu karttatyyppi
Hallitse mukautettuja karttatasoja
+ Lisää karttatiilien verkkolähde
+ Mukautettuja karttalähteitä ei löytynyt.
+ Muokkaa karttatiilien verkkolähteen asetuksia
+ Poista verkkokarttalähde
Nimi ei voi olla tyhjä.
Palveluntarjoajan nimi on olemassa.
URL-osoite ei voi olla tyhjä.
@@ -1156,4 +1162,55 @@
Päivitä
Päivitetty
+ Lisää verkkokarttataso
+ Päivitä karttataso
+ Paikallinen MBTiles-karttatiedosto
+ Lisää paikallinen MBTiles-karttatiedosto
+ Virheellinen nimi, URL-malli tai paikallinen URI mukautetulle karttalähteelle.
+ Mukautettu karttalähde tällä nimellä on jo olemassa.
+ MBTiles-tiedoston kopiointi sisäiseen tallennustilaan epäonnistui.
+ TAK (ATAK)
+ TAK-asetukset
+ Tiimin väri
+ Jäsenen rooli
+ Määrittelemätön
+ Valkoinen
+ Keltainen
+ Oranssi
+ Purppura
+ Punainen
+ Viininpunainen
+ Liila
+ Tummansininen
+ Sininen
+ Turkoosi
+ Sinivihreä
+ Vihreä
+ Tummanvihreä
+ Ruskea
+ Määrittelemätön
+ Tiimin jäsen
+ Joukkueen johtaja
+ Päämaja
+ Tarkka-ampuja
+ Lääkäri
+ Havaitsija etulinjassa
+ Radiopuhelinoperaattori
+ Koiraseuranta (K9)
+ Liikenteenhallinta
+ Liikenteen hallinnan asetukset
+ Moduuli käytössä
+ Sijaintiduplikaattien poisto (liikenteenhallinta)
+ Sijainnin tarkkuus (bitteinä)
+ Sijainnin vähimmäislähetysväli (sekunteina)
+ Laitetietojen suora vastaus
+ Suoran vastauksen enimmäishyppyjen määrä
+ Lähetysnopeuden rajoitus
+ Lähetysrajoituksen aikajakso (sekunteina)
+ Pakettien enimmäismäärä aikajaksossa
+ Tuntemattomien pakettien hylkääminen
+ Tuntemattomien pakettien kynnysarvo
+ Telemetria vain paikallisesti (välittäjät)
+ Sijainti vain paikallisesti (välittäjät)
+ Säilytä välittäjien hypyt
diff --git a/core/resources/src/commonMain/composeResources/values-fr/strings.xml b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
index 3a252b924..3aa4b7e71 100644
--- a/core/resources/src/commonMain/composeResources/values-fr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fr/strings.xml
@@ -1152,4 +1152,8 @@
Actualiser
Mis à jour
+ Rouge
+ Bleu
+ Vert
+ Module activé
diff --git a/core/resources/src/commonMain/composeResources/values-hr/strings.xml b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
index 11fad4638..e9093c157 100644
--- a/core/resources/src/commonMain/composeResources/values-hr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hr/strings.xml
@@ -173,4 +173,5 @@
+ Crveno
diff --git a/core/resources/src/commonMain/composeResources/values-hu/strings.xml b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
index 9b6088405..5f68ad29f 100644
--- a/core/resources/src/commonMain/composeResources/values-hu/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-hu/strings.xml
@@ -928,4 +928,7 @@
Összes
Bluetooth
+ Piros
+ Kék
+ Zöld
diff --git a/core/resources/src/commonMain/composeResources/values-it/strings.xml b/core/resources/src/commonMain/composeResources/values-it/strings.xml
index 6a734a54a..591177ca8 100644
--- a/core/resources/src/commonMain/composeResources/values-it/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-it/strings.xml
@@ -942,4 +942,8 @@
Bluetooth
Configurazione
+ Rosso
+ Blu
+ Verde
+ Modulo abilitato
diff --git a/core/resources/src/commonMain/composeResources/values-ja/strings.xml b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
index 109f5e579..39e633de7 100644
--- a/core/resources/src/commonMain/composeResources/values-ja/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ja/strings.xml
@@ -26,6 +26,7 @@
インフラを除外
オフラインノードを非表示
ダイレクトノードのみ表示
+ 無視されたノードを表示しています。\nノード一覧に戻るにはここを押してください。
詳細を表示
並べ替え
ノードの並べ替えオプション
@@ -40,9 +41,11 @@
API 経由
内部
お気に入り経由
+ 無視されたノードのみ表示
不明
相手の受信確認待ち
送信待ち
+ SF++ チェーンで確認済み
相手の受信を確認しました
ルートがありません
相手が正常に受信できませんでした
@@ -59,43 +62,91 @@
不明な公開キー
セッションキーが不正です
許可されていない公開キー
+ PKIの送信に失敗しました、公開鍵はありません
+ クライアント
アプリに接続されているか、スタンドアロンのメッセージングデバイスです。
+ クライアント・ミュート
このデバイスは他のデバイスからのパケットを転送しません。
+ クライアント・ベース
+ ルーター
メッセージを中継することでネットワークの通信範囲を拡大するためのインフラストラクチャノード。ノードリストに表示されます。
+ ルータークライアント
ROUTERとCLIENTの組み合わせ。モバイルデバイス向けではありません。
+ リピーター
最小限のオーバーヘッドでメッセージを中継することでネットワークの通信範囲を拡大するためのインフラストラクチャノード。ノードリストには表示されません。
+ トラッカー
GPSの位置情報パケットを優先してブロードキャストします。
+ センサー
テレメトリーパケットを優先してブロードキャストします。
+ TAK
ATAKシステムとの通信に最適化し、定期的なブロードキャストを削減します。
+ クライアント・非表示
ステルスまたは電力節約のため、必要に応じてのみブロードキャストするデバイス。
+ 紛失モード
デバイスを見つけやすくするために、デバイス自身の位置情報をメッセージ形式で定期的にデフォルトのチャンネルにブロードキャストします。
+ TAK Tracker
TAK PLIの自動ブロードキャストを有効にし、ルーチンブロードキャストを削減します。
+ ルーター・レイト
周辺クラスターの通信範囲を拡大させるインフラストラクチャノード。他のすべてのノードが通信し終わった後で、必ずパケットを1回だけ再ブロードキャストする。ノードリストに表示される。
すべて
受信メッセージが、参加しているプライベートチャンネル上のもの、または同じLoRaパラメータを持つ別のメッシュからのものであれば再ブロードキャストします。
+ すべてをスキップ
ALLと同じ動作ですが、パケットのデコードをスキップして単純に再ブロードキャストします。 リピーターロールでのみ使用できます。他のロールに設定すると、ALLの動作になります。
+ ローカルのみ
開いている外部メッシュや復号できないメッシュからのメッセージを無視。 ノードのローカルプライマリー/セカンダリーチャンネルでのみメッセージを再ブロードキャスト。
+ 既知のみ
LOCAL ONLYのような外部メッシュからのメッセージを無視します。 さらに一歩進んで既知のノードリストにないノードからのメッセージを無視します。
なし
SENSOR、TRACKER、およびTAK_TRACKERロールでのみ許可。CLIENT_MUTEロールとは異なり、すべての再ブロードキャストを禁止。
+ コアポート番号のみ
TAK、RangeTest、PaxCounterなどの非標準ポート番号からのパケットを無視。NodeInfo、Text、Position、Telemetry、Routingなどの標準ポート番号を持つパケットのみを再ブロードキャスト。
加速度センサー搭載デバイスで本体をダブルタップすると、ボタンのプッシュと同じ動作として扱います。
+ ユーザーボタンがトリプルクリックされている場合、プライマリチャンネル上の位置を送信します。
デバイスの点滅するLEDを制御します。ほとんどのデバイスでは、最大4つあるLEDのうちの1つを制御します。充電用LEDとGPS用LEDは制御できません。
+ デバイスの画面とログ上の日付のタイムゾーン。
+ 端末のタイムゾーンを使用
近隣ノード情報(NeighborInfo)をMQTTやPhoneAPIへ送信することに加えて、LoRa無線経由でも送信すべきかどうかを設定します。デフォルトの名前とキーが設定されたチャンネルでは利用できません。
+ ユーザーボタンが押された後、またはメッセージが受信された後、画面がオンになっている期間。
+ 指定した間隔に基づき、画面上でカルーセルのように自動的に次のページに切り替わります。
+ 円外側の画面上のコンパス方位は、常に北を指します。
+ 画面を上下に反転させる。
+ デバイスの画面に表示されている単位。
+ OLED 画面の自動検出を上書きします。
+ デフォルト画面レイアウトを上書きします。
+ 画面の見出しテキストを太字にします。
+ お使いの端末に加速度センサーがあることが必要です。
+ 無線端末を使用される地域を指定してくだい。
+ 使用可能なモデムプリセット、デフォルトはロングファーストです。
+ 最大ホップ数を設定し、初期値は3ホップです。ホップ数を増やすと輻輳も増加するため、控えめに運用しましょう。0ホップのブロードキャストメッセージはACKを受信しなくなります。
UDP 経由でローカルネットワーク上のパケットのブロードキャスト通信を有効にする。
+ ノードが位置情報をブロードキャストせずに経過し得る最大間隔。
+ スマート間隔
+ スマート距離
+ デバイスの GPS
+ 固定位置
+ 標高
+ GPS ポーリング間隔
+ 高度なデバイス GPS
+ GPS RX GPIO
+ GPS TX GPIO
+ GPS EN GPIO
GPIO
デバッグ
+ チャネ
チャンネル名
QRコード
ユーザー名不明
送信
このスマートフォンはMeshtasticデバイスとペアリングされていません。デバイスとペアリングしてユーザー名を設定してください。\n\nこのオープンソースアプリケーションはアルファテスト中です。問題を発見した場合はBBSに書き込んでください。 https://github.com/orgs/meshtastic/discussions\n\n詳しくはWEBページをご覧ください。 www.meshtastic.org
あなた
+ 分析とクラッシュレポートを許可する。
同意
キャンセル
+ 破棄
保存
新しいチャンネルURLを受信しました
+ Meshtasticは、新規デバイスをBluetooth経由で検出するために位置情報の許可を有効にする必要があります。非使用時は無効にすることができます。
バグを報告
バグを報告
不具合報告として診断情報を送信しますか?送信した場合は https://github.com/orgs/meshtastic/discussions に検証できる報告を書き込んでください。
@@ -104,6 +155,7 @@
ペアに設定できませんでした。もう一度選択してください。
位置情報が無効なため、メッシュネットワークに位置情報を提供できません。
シェア
+ 新しいノードを見ました:%1$s
切断
デバイスはスリープ状態です
接続済み: %1$s オンライン
@@ -112,15 +164,47 @@
接続済
Meshtasticデバイスに接続しました
(%1$s)
+ 現在の接続:
+ Wi-Fi IP:
+ イーサネット IP:
接続中
接続されていません
+ デバイスが選択されていません
接続しましたが、Meshtasticデバイスはスリープ状態です。
アプリを更新して下さい。
アプリが古く、デバイスと通信ができません。アプリストアまたはGithubでアプリを更新してください。詳細はこちら に記載されています。
なし (切断)
通知サービス
+ 謝辞
このチャンネルURLは無効なため使用できません。
+ この連絡先は無効なので追加できません
デバッグ
+ デコードされたペイロード:
+ ログのエクスポート
+ エクスポートがキャンセルされました
+ %1$d ログをエクスポートしました
+ ログファイルの書き込みに失敗しました:%1$s
+
+ - %1$d 時間
+
+
+ - %1$d 日
+
+ フィルタ
+ 適用中のフィルタ
+ ログ内で検索…
+ 次の一致
+ 前の一致
+ 検索をクリア
+ フィルタ追加
+ フィルタを含む
+ すべてのフィルタをクリア
+ カスタムフィルタを追加
+ プリセットフィルタ
+ 無視したノードのみを表示
+ メッシュログを保存
+ 無効にすると、メッシュログをファイルに保存することがスキップされます
+ ログをクリア
削除
メッセージ配信状況
アラート通知
@@ -557,7 +641,83 @@
削除
+ フィルタを無効にする
+ チャンネル URL
+ NFCをスキャンする
+ 共有連絡先の NFC をスキャン
+ 共有連絡先のQRコードをスキャン
+ 共有連絡先のURLを入力
+ チャンネルの NFC をスキャンする
+ チャンネルのQRコードをスキャンする
+ チャンネルURLを入力
+ チャンネルのQRコードを共有
+ NFCタグに端末を近づけてスキャンしてください。
+ QRコード生成
+ NFC が無効になっています。システム設定で有効にしてください。
すべて
Bluetooth
+ Configure Bluetooth Permissions
+ Meshtasticデバイスに接続しました
+ Meshtastic メッシュ無線デバイスをスキャンして接続します。
+ ディスカバリー
+ あなたの近くにあるMeshtasticデバイスを見つけて識別します。
+ 設定
+ デバイスの設定とチャンネルをワイヤレスで管理します。
+ 許可が与えられました
+ 許可が拒否されました
+ マップスタイルの選択
+ バッテリー:%1$d%%
+ 稼働時間: %1$s
+ ChUtil: %1$.2f%% | AirTX: %2$.2f%%
+ トラフィック: TX %1$d / RX %2$d (D: %3$d)
+ リレー: %1$d (キャンセル済み: %2$d)
+ 診断: %1$s
+ ノイズ %1$d dBm
+ ドロップされた %1$d
+ ヒープ
+ %1$d / %2$d
+ %1$s
+ 給電
+ Meshtastic 統計
+ 更新
+ 更新済み
+ ネットレイヤーを追加
+ レイヤーを更新
+ ローカル MBTiles ファイル
+ ローカル MBTiles ファイルを追加する
+ カスタムタイルプロバイダーのファイル名、URLテンプレート、またはローカルURIが無効です。
+ この名前のカスタムタイルプロバイダーが既に存在します。
+ MBTilesファイルを内部ストレージにコピーできませんでした。
+ TAK (ATAK)
+ TAK 設定
+ チームカラー
+ メンバーロール
+ 未指定
+ 白色
+ 黄色
+ 柿色
+ 紅紫色
+ 赤
+ 栗色
+ 紫色
+ 紺色
+ 青
+ 浅葱色
+ 鴨の羽色
+ 緑
+ 柚葉色
+ 茶色
+ 未指定
+ チームメンバー
+ チームリーダー
+ 本部
+ スナイパー
+ 衛生兵
+ 前線観測員 (FO)
+ 無線通信手
+ イッヌ (K9)
+ トラフィック管理
+ トラフィック管理設定
+ モジュール有効
diff --git a/core/resources/src/commonMain/composeResources/values-ko/strings.xml b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
index ebb0190d4..d9e077601 100644
--- a/core/resources/src/commonMain/composeResources/values-ko/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ko/strings.xml
@@ -569,4 +569,7 @@
블루투스
설정
+ 빨강
+ 파랑
+ 초록
diff --git a/core/resources/src/commonMain/composeResources/values-lt/strings.xml b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
index 0ed59d06a..17dc9457b 100644
--- a/core/resources/src/commonMain/composeResources/values-lt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-lt/strings.xml
@@ -248,4 +248,5 @@
+ Raudona
diff --git a/core/resources/src/commonMain/composeResources/values-nl/strings.xml b/core/resources/src/commonMain/composeResources/values-nl/strings.xml
index 9f9cd310c..7b46c7887 100644
--- a/core/resources/src/commonMain/composeResources/values-nl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-nl/strings.xml
@@ -437,4 +437,7 @@
Alles
Bluetooth
+ Rood
+ Blauw
+ Groen
diff --git a/core/resources/src/commonMain/composeResources/values-pl/strings.xml b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
index 1c617170d..0bfa412e4 100644
--- a/core/resources/src/commonMain/composeResources/values-pl/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pl/strings.xml
@@ -812,4 +812,8 @@
Bluetooth
Konfiguracja
+ Czerwony
+ Niebieski
+ Zielony
+ Moduł Włączony
diff --git a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
index 8efbac0df..ad24867db 100644
--- a/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt-rBR/strings.xml
@@ -706,4 +706,7 @@
Bluetooth
+ Vermelho
+ Azul
+ Verde
diff --git a/core/resources/src/commonMain/composeResources/values-pt/strings.xml b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
index 5bfc89042..c5e56e3c4 100644
--- a/core/resources/src/commonMain/composeResources/values-pt/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-pt/strings.xml
@@ -555,4 +555,7 @@
Bluetooth
Configuração
+ Vermelho
+ Azul
+ Verde
diff --git a/core/resources/src/commonMain/composeResources/values-ro/strings.xml b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
index 79dafabb3..91140efa5 100644
--- a/core/resources/src/commonMain/composeResources/values-ro/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ro/strings.xml
@@ -640,4 +640,7 @@
Toate
Bluetooth
+ Roșu
+ Albastru
+ Verde
diff --git a/core/resources/src/commonMain/composeResources/values-ru/strings.xml b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
index af5c367d7..348f196ca 100644
--- a/core/resources/src/commonMain/composeResources/values-ru/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-ru/strings.xml
@@ -722,6 +722,7 @@
Полное имя
Короткое имя
Модель оборудования
+ Лицензия радиолюбителя (HAM)
Включение данной опции отключает шифрование и несовместимо с основной сетью Meshtastic.
Точка росы
Давление
@@ -940,7 +941,9 @@
Ландшафт
Смешанный
Управление Слоями Карты
+ Слои карты поддерживают форматы .kml, .kmz или GeoJSON.
Слои карты
+ Слои карты не загружены.
Добавить слой
Скрыть слой
Показать слой
@@ -949,6 +952,10 @@
Ноды в этом месте
Выбранный тип карты
Управление собственными источниками плиток
+ Добавить источник сетевых плиток
+ Источники пользовательских плиток не найдены.
+ Редактировать источник сетевых плиток
+ Удалить источник сетевых плиток
Имя не может быть пустым.
Имя провайдера уже существует.
URL не может быть пустым.
@@ -1170,4 +1177,47 @@
Обновить
Обновлено
+ Добавить сетевой уровень
+ Обновить уровень
+ Недопустимое имя, шаблон URL или локальный URI для провайдера плиток пользователя.
+ Провайдер плиток с этим именем уже существует.
+ Не удалось скопировать файл MBTiles во внутреннее хранилище.
+ TAK (ATAK)
+ Настройка TAK
+ Цвет команды
+ Роль участника
+ Не указан
+ Белый
+ Жёлтый
+ Оранжевый
+ Пурпурный
+ Красный
+ Бордовый
+ Фиолетовый
+ Тёмно-синий
+ Синий
+ Голубой
+ Бирюзовый
+ Зеленый
+ Тёмно-зеленый
+ Коричневый
+ Не определена
+ Участник команды
+ Руководитель команды
+ Штаб-квартира
+ Снайпер
+ Санитар
+ Наблюдатель
+ Оператор радиотелефона
+ Собака (К9)
+ Управление движением
+ Настройка управления движением
+ Телеметрия окружающей среды
+ Удаление дубликатов позиций
+ Точность позиции (бит)
+ Мин. интервал позиционирования (сек)
+ Ограничение скорости
+ Окно ограничения скорости (сек.)
+ Макс количество пакетов в окне
+ Сохраняить хопы маршрутизатора
diff --git a/core/resources/src/commonMain/composeResources/values-sk/strings.xml b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
index 2e7ec0cda..b15f7c609 100644
--- a/core/resources/src/commonMain/composeResources/values-sk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sk/strings.xml
@@ -460,4 +460,7 @@
Všetky
Bluetooth
+ Červená
+ Modrá
+ Zelená
diff --git a/core/resources/src/commonMain/composeResources/values-sv/strings.xml b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
index 54bb8ec09..b29c6f373 100644
--- a/core/resources/src/commonMain/composeResources/values-sv/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-sv/strings.xml
@@ -1031,4 +1031,8 @@
Bluetooth
Konfiguration
+ Rött
+ Blått
+ Grönt
+ Modul aktiverad
diff --git a/core/resources/src/commonMain/composeResources/values-tr/strings.xml b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
index 9bad5ac60..b6e256741 100644
--- a/core/resources/src/commonMain/composeResources/values-tr/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-tr/strings.xml
@@ -576,4 +576,7 @@
Bluetooth
Yapılandırma
+ Kırmızı
+ Mavi
+ Yeşil
diff --git a/core/resources/src/commonMain/composeResources/values-uk/strings.xml b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
index 486ed99ab..4f7ddbeb6 100644
--- a/core/resources/src/commonMain/composeResources/values-uk/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-uk/strings.xml
@@ -795,4 +795,7 @@
Bluetooth
Налаштування
+ Червоний
+ Синій
+ Зелений
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
index d008c928f..5ffc2d6d5 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rCN/strings.xml
@@ -1154,4 +1154,8 @@
刷新
更新
+ 红
+ 蓝
+ 绿
+ 开启模块
diff --git a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
index 17a1812e8..a87e8a84e 100644
--- a/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -941,7 +941,7 @@
已選擇的地圖類型
管理自定義圖磚來源
加入自定義圖磚來源
- 沒有自定義圖專來源
+ 沒有自定義圖專來源。
編輯自定義圖磚來源
刪除自定義圖磚來源
名稱不得空白。
@@ -1161,4 +1161,48 @@
自訂圖磚來源的名稱、URL 範本或本機 URI 無效。
已存在相同名稱的自訂圖磚來源。
無法將 MBTiles 檔案複製至內部儲存空間。
+ TAK (ATAK)
+ TAK 設定
+ 隊伍顏色
+ 隊員角色
+ 未指定
+ 白色
+ 黃色
+ 橙色
+ 洋紅色
+ Red - 紅色
+ 栗紅色
+ 紫色
+ 深藍色
+ Blue - 藍色
+ 天青色
+ 羽青色
+ Green - 綠色
+ 墨綠色
+ 咖啡色
+ 未指定
+ 隊伍成員
+ 隊長
+ 司令部 (HQ)
+ 狙擊手
+ 醫療兵
+ 前進觀測員 (FO)
+ 無線電兵
+ 汪星人 (K9)
+ 流量管理
+ 流量管理設定
+ 模組已啟用
+ 定位去重複化處理
+ 定位精度(位元)
+ 定位最小間隔時間(秒)
+ 節點資訊直接直接應答
+ 直接應答最大跳數
+ 速率限制
+ 速率限制開放窗口期(秒)
+ 開放窗口期封包上限
+ 捨棄不明封包
+ 不明封包閾值
+ 僅本地遙測資訊(中繼)
+ 僅本地定位資訊(中繼)
+ 保留路由跳數
From 9ba4d50e601062a0543514f3215415b41ac4c5dd Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 2 Mar 2026 08:52:46 -0600
Subject: [PATCH 041/474] chore(deps): update vico to v3.0.2 (#4675)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 124528478..c7f3a6ff1 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -55,7 +55,7 @@ okio = "3.16.4"
osmdroid-android = "6.1.20"
spotless = "8.2.1"
wire = "6.0.0-alpha02"
-vico = "3.0.1"
+vico = "3.0.2"
dependency-guard = "0.5.0"
nordic-ble = "2.0.0-alpha15"
nordic-common = "2.9.1"
From 5f31df96d8242db080b667f81f11b842be3f66cc Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 2 Mar 2026 12:15:23 -0600
Subject: [PATCH 042/474] chore(deps): update
androidx.compose:compose-bom-alpha to v2026.02.01 (#4673)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c7f3a6ff1..d51b3cce8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -110,7 +110,7 @@ androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", versi
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
# AndroidX Compose
-androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.02.00" }
+androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.02.01" }
androidx-compose-material-iconsExtended = { module = "androidx.compose.material:material-icons-extended" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-navigationSuite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" }
From 8c6bd8ab7aae6f0e01a6277046941edf7b74e8a4 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 2 Mar 2026 12:15:33 -0600
Subject: [PATCH 043/474] feat: settings rework part 2, domain and usecase
abstraction, tests (#4680)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
app/src/main/AndroidManifest.xml | 19 +-
.../repository/radio/NordicBleInterface.kt | 4 +
.../repository/radio/RadioInterfaceService.kt | 5 +-
.../mesh/service/ConnectionStateHandler.kt | 2 +-
.../mesh/service/MeshCommandSender.kt | 43 +-
.../mesh/service/MeshConfigFlowManager.kt | 2 +-
.../mesh/service/MeshConnectionManager.kt | 30 +-
.../mesh/service/MeshMessageProcessor.kt | 2 +-
.../mesh/service/MeshNodeManager.kt | 2 +-
.../mesh/service/MeshServiceBroadcasts.kt | 2 +-
.../service/MeshServiceNotificationsImpl.kt | 2 +-
.../geeksville/mesh/service/PacketHandler.kt | 2 +-
.../main/java/com/geeksville/mesh/ui/Main.kt | 2 +-
.../mesh/ui/connections/ConnectionsScreen.kt | 2 +-
.../ui/connections/components/BLEDevices.kt | 2 +-
.../components/ConnectionsNavIcon.kt | 5 +-
.../connections/components/DeviceListItem.kt | 2 +-
.../components/DeviceListSection.kt | 2 +-
.../connections/components/NetworkDevices.kt | 2 +-
.../ui/connections/components/UsbDevices.kt | 2 +-
.../com/geeksville/mesh/ui/sharing/Channel.kt | 2 +-
.../mesh/widget/LocalStatsWidget.kt | 2 +-
.../mesh/widget/LocalStatsWidgetState.kt | 2 +-
.../service/MeshCommandSenderHopLimitTest.kt | 2 +-
.../service/MeshCommandSenderQueueTest.kt | 122 ----
.../mesh/service/MeshConnectionManagerTest.kt | 31 +-
.../mesh/service/MeshServiceBroadcastsTest.kt | 2 +-
.../mesh/service/PacketHandlerTest.kt | 2 +-
.../HomoglyphCharacterStringTransformer.kt | 6 +-
.../core/common/util/SequentialJob.kt | 5 +-
.../core/data/repository/NodeRepository.kt | 8 +-
.../data/repository/RadioConfigRepository.kt | 4 +-
.../core/database/DatabaseManager.kt | 9 +-
core/di/build.gradle.kts | 4 +-
.../org/meshtastic/core/di/AppModule.kt | 11 +-
core/domain/build.gradle.kts | 44 ++
.../meshtastic/core/domain/MessageQueue.kt | 25 +
.../domain/usecase/SendMessageUseCase.kt | 62 +-
.../usecase/settings/AdminActionsUseCase.kt | 92 +++
.../settings/CleanNodeDatabaseUseCase.kt | 63 ++
.../usecase/settings/ExportDataUseCase.kt | 122 ++++
.../usecase/settings/ExportProfileUseCase.kt | 35 +
.../settings/ExportSecurityConfigUseCase.kt | 58 ++
.../usecase/settings/ImportProfileUseCase.kt | 35 +
.../usecase/settings/InstallProfileUseCase.kt | 153 ++++
.../usecase/settings/IsOtaCapableUseCase.kt | 65 ++
.../usecase/settings/MeshLocationUseCase.kt | 33 +
.../settings/ProcessRadioResponseUseCase.kt | 127 ++++
.../usecase/settings/RadioConfigUseCase.kt | 187 +++++
.../settings/SetAppIntroCompletedUseCase.kt | 27 +
.../settings/SetDatabaseCacheLimitUseCase.kt | 29 +
.../settings/SetMeshLogSettingsUseCase.kt | 54 ++
.../settings/SetProvideLocationUseCase.kt | 27 +
.../usecase/settings/SetThemeUseCase.kt | 27 +
.../settings/ToggleAnalyticsUseCase.kt | 27 +
.../ToggleHomoglyphEncodingUseCase.kt | 27 +
.../core/domain/FakeRadioController.kt | 109 +++
.../domain/usecase/SendMessageUseCaseTest.kt | 64 +-
.../settings/AdminActionsUseCaseTest.kt | 72 ++
.../settings/CleanNodeDatabaseUseCaseTest.kt | 73 ++
.../usecase/settings/ExportDataUseCaseTest.kt | 99 +++
.../settings/ExportProfileUseCaseTest.kt | 48 ++
.../ExportSecurityConfigUseCaseTest.kt | 61 ++
.../settings/ImportProfileUseCaseTest.kt | 60 ++
.../settings/InstallProfileUseCaseTest.kt | 98 +++
.../settings/IsOtaCapableUseCaseTest.kt | 124 ++++
.../settings/MeshLocationUseCaseTest.kt | 47 ++
.../ProcessRadioResponseUseCaseTest.kt | 106 +++
.../settings/RadioConfigUseCaseTest.kt | 160 +++++
.../SetAppIntroCompletedUseCaseTest.kt | 44 ++
.../SetDatabaseCacheLimitUseCaseTest.kt | 49 ++
.../settings/SetMeshLogSettingsUseCaseTest.kt | 74 ++
.../settings/SetProvideLocationUseCaseTest.kt | 44 ++
.../usecase/settings/SetThemeUseCaseTest.kt | 44 ++
.../settings/ToggleAnalyticsUseCaseTest.kt | 60 ++
.../ToggleHomoglyphEncodingUseCaseTest.kt | 60 ++
.../meshtastic/core/model}/ConnectionState.kt | 5 +-
.../meshtastic/core/model/RadioController.kt | 90 +++
core/service/build.gradle.kts | 7 +-
.../service/AndroidRadioControllerImpl.kt | 161 +++++
.../core/service/ServiceRepository.kt | 5 +-
.../core/service/di/ServiceModule.kt | 31 +
.../firmware/FirmwareUpdateViewModel.kt | 2 +-
.../feature/intro/AppIntroductionScreen.kt | 2 +-
.../feature/map/MapViewModelTest.kt | 2 +-
feature/messaging/build.gradle.kts | 9 +
.../meshtastic/feature/messaging/Message.kt | 1 +
.../feature/messaging/MessageViewModel.kt | 2 +-
.../feature/messaging/di/MessagingModule.kt | 31 +
.../domain/worker/SendMessageWorker.kt | 70 ++
.../domain/worker/WorkManagerMessageQueue.kt | 43 ++
.../HomoglyphCharacterTransformTest.kt | 1 +
.../domain/worker/SendMessageWorkerTest.kt | 159 +++++
.../feature/node/component/NodeItem.kt | 2 +-
.../feature/node/component/NodeStatusIcons.kt | 2 +-
.../feature/node/list/NodeListScreen.kt | 2 +-
feature/settings/build.gradle.kts | 4 +
.../feature/settings/AdministrationScreen.kt | 2 +-
.../settings/DeviceConfigurationScreen.kt | 2 +-
.../settings/ModuleConfigurationScreen.kt | 2 +-
.../feature/settings/SettingsScreen.kt | 316 +--------
.../feature/settings/SettingsViewModel.kt | 213 +-----
.../settings/component/AppInfoSection.kt | 159 +++++
.../settings/component/AppearanceSection.kt | 84 +++
.../settings/component/ExpressiveSection.kt | 56 ++
.../settings/component/HomoglyphSetting.kt | 41 ++
.../settings/component/PersistenceSection.kt | 116 +++
.../settings/component/PrivacySection.kt | 113 +++
.../settings/radio/CleanNodeDatabaseScreen.kt | 8 +-
.../radio/CleanNodeDatabaseViewModel.kt | 59 +-
.../feature/settings/radio/RadioConfig.kt | 239 ++++---
.../settings/radio/RadioConfigViewModel.kt | 658 +++++++-----------
.../feature/settings/HomoglyphSettingTest.kt | 1 +
.../feature/settings/SettingsViewModelTest.kt | 128 ++++
.../settings/debugging/DebugViewModelTest.kt | 115 +++
.../filter/FilterSettingsViewModelTest.kt | 67 ++
.../radio/CleanNodeDatabaseViewModelTest.kt | 82 +++
.../radio/RadioConfigViewModelTest.kt | 241 +++++++
gradle.properties | 79 +--
gradle/libs.versions.toml | 3 +
settings.gradle.kts | 1 +
121 files changed, 5245 insertions(+), 1332 deletions(-)
delete mode 100644 app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt
rename {feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging => core/common/src/commonMain/kotlin/org/meshtastic/core/common/util}/HomoglyphCharacterStringTransformer.kt (97%)
create mode 100644 core/domain/build.gradle.kts
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt
rename {feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging => core/domain/src/main/kotlin/org/meshtastic/core}/domain/usecase/SendMessageUseCase.kt (60%)
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt
create mode 100644 core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt
rename {feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging => core/domain/src/test/kotlin/org/meshtastic/core}/domain/usecase/SendMessageUseCaseTest.kt (69%)
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt
create mode 100644 core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt
rename core/{service/src/main/kotlin/org/meshtastic/core/service => model/src/commonMain/kotlin/org/meshtastic/core/model}/ConnectionState.kt (94%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
create mode 100644 core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
create mode 100644 core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt
create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt
create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt
create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt
create mode 100644 feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt
create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt
create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3c0e623aa..383ee77f1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -198,7 +198,7 @@
-
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
index 3ab5b5300..19e047139 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
@@ -208,6 +208,10 @@ constructor(
onDisconnected(state)
}
}
+ .catch { e ->
+ Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
+ service.onDisconnect(BleError.from(e))
+ }
.launchIn(connectionScope)
val p = retryBleOperation(tag = address) { findPeripheral() }
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
index 0e7215d5c..f7cf8fbd5 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -47,9 +48,9 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.prefs.radio.RadioPrefs
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
@@ -127,6 +128,7 @@ constructor(
stopInterface()
}
}
+ .catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
networkRepository.networkAvailable
@@ -137,6 +139,7 @@ constructor(
stopInterface()
}
}
+ .catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt b/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
index 4db868f22..a9f1cf014 100644
--- a/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
@@ -18,7 +18,7 @@ package com.geeksville.mesh.service
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
import javax.inject.Inject
import javax.inject.Singleton
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
index 3b36c9e19..6e98b253e 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
@@ -30,12 +30,12 @@ import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
@@ -47,7 +47,6 @@ import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
@@ -68,7 +67,6 @@ constructor(
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val currentPacketId = AtomicLong(java.util.Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = AtomicReference(ByteString.EMPTY)
- private val offlineSentPackets = CopyOnWriteArrayList()
val tracerouteStartTimes = ConcurrentHashMap()
val neighborInfoStartTimes = ConcurrentHashMap()
@@ -77,17 +75,6 @@ constructor(
@Volatile var lastNeighborInfo: NeighborInfo? = null
- private val rememberDataType =
- setOf(
- PortNum.TEXT_MESSAGE_APP.value,
- PortNum.ALERT_APP.value,
- PortNum.WAYPOINT_APP.value,
- PortNum.ATAK_PLUGIN.value,
- PortNum.ATAK_FORWARDER.value,
- PortNum.DETECTION_SENSOR_APP.value,
- PortNum.PRIVATE_APP.value,
- )
-
fun start(scope: CoroutineScope) {
this.scope = scope
radioConfigRepository?.localConfigFlow?.onEach { localConfig.value = it }?.launchIn(scope)
@@ -154,14 +141,9 @@ constructor(
}
if (connectionStateHolder?.connectionState?.value == ConnectionState.Connected) {
- try {
- sendNow(p)
- } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
- Logger.e(ex) { "Error sending message, so enqueueing" }
- enqueueForSending(p)
- }
+ sendNow(p)
} else {
- enqueueForSending(p)
+ error("Radio is not connected")
}
}
@@ -185,25 +167,6 @@ constructor(
packetHandler?.sendToRadio(meshPacket)
}
- private fun enqueueForSending(p: DataPacket) {
- if (p.dataType in rememberDataType) {
- offlineSentPackets.add(p)
- }
- }
-
- fun processQueuedPackets() {
- val sentPackets = mutableListOf()
- offlineSentPackets.forEach { p ->
- try {
- sendNow(p)
- sentPackets.add(p)
- } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
- Logger.e(ex) { "Error sending queued message:" }
- }
- }
- offlineSentPackets.removeAll(sentPackets)
- }
-
fun sendAdmin(
destNum: Int,
requestId: Int = generatePacketId(),
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 ad3f64d34..1d666ca2d 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
@@ -27,7 +27,7 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat
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 bd777c538..eeb4882dc 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
@@ -19,6 +19,10 @@ package com.geeksville.mesh.service
import android.app.Notification
import android.content.Context
import androidx.glance.appwidget.updateAll
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.workDataOf
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.widget.LocalStatsWidget
@@ -40,7 +44,9 @@ import org.meshtastic.core.common.util.handledLaunch
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.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.resources.Res
@@ -50,8 +56,8 @@ 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.feature.messaging.domain.worker.SendMessageWorker
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
@@ -82,6 +88,8 @@ constructor(
private val commandSender: MeshCommandSender,
private val nodeManager: MeshNodeManager,
private val analytics: PlatformAnalytics,
+ private val packetRepository: PacketRepository,
+ private val workManager: WorkManager,
) {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var sleepTimeout: Job? = null
@@ -255,7 +263,25 @@ constructor(
}
fun onRadioConfigLoaded() {
- commandSender.processQueuedPackets()
+ scope.handledLaunch {
+ val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
+ queuedPackets.forEach { packet ->
+ try {
+ val workRequest =
+ OneTimeWorkRequestBuilder()
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packet.id))
+ .build()
+
+ workManager.enqueueUniqueWork(
+ "${SendMessageWorker.WORK_NAME_PREFIX}${packet.id}",
+ ExistingWorkPolicy.REPLACE,
+ workRequest,
+ )
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "Failed to enqueue queued packet worker" }
+ }
+ }
+ }
val myNodeNum = nodeManager.myNodeNum ?: 0
// Set time
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
index f1da54dd7..7ed7980c3 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
@@ -60,7 +60,7 @@ constructor(
private val logInsertJobByPacketId = ConcurrentHashMap()
private val earlyReceivedPackets = ArrayDeque()
- private val maxEarlyPacketBuffer = 128
+ private val maxEarlyPacketBuffer = 10240
fun clearEarlyPackets() {
synchronized(earlyReceivedPackets) { earlyReceivedPackets.clear() }
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 ce6d4431c..1f284c7a7 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
@@ -78,7 +78,7 @@ constructor(
fun loadCachedNodeDB() {
scope.handledLaunch {
- val nodes = nodeRepository?.getNodeDBbyNum()?.first() ?: emptyMap()
+ val nodes = nodeRepository?.getNodeEntityDBbyNumFlow()?.first() ?: emptyMap()
nodeDBbyNodeNum.putAll(nodes)
nodes.values.forEach { nodeDBbyID[it.user.id] = it }
myNodeNum = nodeRepository?.myNodeInfo?.value?.myNodeNum
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
index e0215bc15..34ce09dec 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
@@ -21,11 +21,11 @@ import android.content.Intent
import android.os.Parcelable
import co.touchlab.kermit.Logger
import dagger.hilt.android.qualifiers.ApplicationContext
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.util.toPIIString
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import java.util.Locale
import javax.inject.Inject
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 6128caaf6..babdc5565 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
@@ -309,7 +309,7 @@ constructor(
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() }
+ val nodes = runBlocking { repo.getNodeEntityDBbyNumFlow().first() }
nodes[myNodeNum]?.let { entity ->
if (cachedDeviceMetrics == null) {
cachedDeviceMetrics = entity.deviceTelemetry.device_metrics
diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
index 2de292491..d85edd7ad 100644
--- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
@@ -32,11 +32,11 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
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 8a31155eb..f28f98114 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -96,6 +96,7 @@ import no.nordicsemi.android.common.permissions.notification.RequestNotification
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
@@ -123,7 +124,6 @@ import org.meshtastic.core.resources.should_update
import org.meshtastic.core.resources.should_update_firmware
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
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 a7b34c125..27cb87e24 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
@@ -59,6 +59,7 @@ import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
@@ -71,7 +72,6 @@ import org.meshtastic.core.resources.must_set_region
import org.meshtastic.core.resources.no_device_selected
import org.meshtastic.core.resources.not_connected
import org.meshtastic.core.resources.set_your_region
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.TitledCard
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
index ed0a540bb..2ea0bda92 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
@@ -37,9 +37,9 @@ import no.nordicsemi.android.common.scanner.view.ScannerView
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_available_devices
-import org.meshtastic.core.service.ConnectionState
/**
* Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning. It handles Bluetooth
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
index a99053754..03be8458b 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.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 com.geeksville.mesh.ui.connections.components
import androidx.compose.animation.Crossfade
@@ -39,7 +38,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.geeksville.mesh.ui.connections.DeviceType
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.ui.icon.Device
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
index 78e64088c..0ab39bbe7 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
@@ -56,12 +56,12 @@ import com.geeksville.mesh.model.DeviceListEntry
import kotlinx.coroutines.delay
import no.nordicsemi.android.common.ui.view.RssiIcon
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
import org.meshtastic.core.resources.bluetooth
import org.meshtastic.core.resources.network
import org.meshtastic.core.resources.serial
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.NodeChip
private const val RSSI_UPDATE_RATE_MS = 2000L
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
index 1915cfff3..2381d4f97 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
@@ -28,7 +28,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
@Composable
fun List.DeviceListSection(
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 cc0f8af7a..8cda4687c 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
@@ -53,6 +53,7 @@ import com.geeksville.mesh.ui.connections.ScannerViewModel
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.isValidAddress
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_network_device
import org.meshtastic.core.resources.address
@@ -63,7 +64,6 @@ import org.meshtastic.core.resources.forget_connection
import org.meshtastic.core.resources.ip_port
import org.meshtastic.core.resources.no_network_devices
import org.meshtastic.core.resources.recent_network_devices
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.theme.AppTheme
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
index fe7aa4d70..9669e83c8 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
@@ -27,9 +27,9 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.ui.connections.ScannerViewModel
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.no_usb_devices
-import org.meshtastic.core.service.ConnectionState
@Composable
fun UsbDevices(
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 693dbf61f..24bcff02f 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
@@ -71,6 +71,7 @@ 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.ConnectionState
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.qrCode
import org.meshtastic.core.navigation.Route
@@ -88,7 +89,6 @@ import org.meshtastic.core.resources.replace
import org.meshtastic.core.resources.reset
import org.meshtastic.core.resources.reset_to_defaults
import org.meshtastic.core.resources.share_channels_qr
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.AdaptiveTwoPane
import org.meshtastic.core.ui.component.ChannelSelection
import org.meshtastic.core.ui.component.MainAppBar
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
index 7de8359eb..1e7f58323 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
@@ -69,6 +69,7 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
@@ -92,7 +93,6 @@ import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.updated
import org.meshtastic.core.resources.uptime
-import org.meshtastic.core.service.ConnectionState
class LocalStatsWidget : GlanceAppWidget() {
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
index 75dc02cd1..eafbe38a2 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
@@ -30,8 +30,8 @@ import kotlinx.coroutines.flow.stateIn
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.onlineTimeThreshold
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.LocalStats
import javax.inject.Inject
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
index 9fcb5ab91..c7f2e2e87 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
@@ -30,8 +30,8 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt
deleted file mode 100644
index e1c0cca2f..000000000
--- a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * 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 com.geeksville.mesh.service
-
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.verify
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import okio.ByteString
-import org.junit.Before
-import org.junit.Test
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.proto.PortNum
-
-class MeshCommandSenderQueueTest {
-
- private val packetHandler = mockk(relaxed = true)
- private val connectionStateHandler = mockk(relaxed = true)
- private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected)
-
- private lateinit var commandSender: MeshCommandSender
-
- @Before
- fun setUp() {
- every { connectionStateHandler.connectionState } returns connectionStateFlow.asStateFlow()
- commandSender = MeshCommandSender(packetHandler, null, connectionStateHandler, null)
- }
-
- @Test
- fun `sendData queues TEXT_MESSAGE_APP when disconnected`() {
- val packet = DataPacket(dataType = PortNum.TEXT_MESSAGE_APP.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @Test
- fun `sendData queues ATAK_PLUGIN when disconnected`() {
- val packet = DataPacket(dataType = PortNum.ATAK_PLUGIN.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @Test
- fun `sendData queues ATAK_FORWARDER when disconnected`() {
- val packet = DataPacket(dataType = PortNum.ATAK_FORWARDER.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @Test
- fun `sendData queues DETECTION_SENSOR_APP when disconnected`() {
- val packet = DataPacket(dataType = PortNum.DETECTION_SENSOR_APP.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @Test
- fun `sendData queues PRIVATE_APP when disconnected`() {
- val packet = DataPacket(dataType = PortNum.PRIVATE_APP.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @Test
- fun `sendData does NOT queue IP_TUNNEL_APP when disconnected`() {
- val packet = DataPacket(dataType = PortNum.IP_TUNNEL_APP.value, bytes = ByteString.EMPTY)
- commandSender.sendData(packet)
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
- }
-}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
index c7e002ec0..cefdb7b61 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
@@ -19,6 +19,9 @@ package com.geeksville.mesh.service
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.updateAll
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import io.mockk.coEvery
import io.mockk.every
@@ -37,12 +40,15 @@ import org.junit.Before
import org.junit.Test
import org.meshtastic.core.analytics.platform.PlatformAnalytics
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.MyNodeEntity
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.ui.UiPrefs
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
@@ -67,6 +73,8 @@ class MeshConnectionManagerTest {
private val commandSender: MeshCommandSender = mockk(relaxed = true)
private val nodeManager: MeshNodeManager = mockk(relaxed = true)
private val analytics: PlatformAnalytics = mockk(relaxed = true)
+ private val packetRepository: PacketRepository = mockk(relaxed = true)
+ private val workManager: WorkManager = mockk(relaxed = true)
private val radioConnectionState = MutableStateFlow(ConnectionState.Disconnected)
private val localConfigFlow = MutableStateFlow(LocalConfig())
private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig())
@@ -107,6 +115,8 @@ class MeshConnectionManagerTest {
commandSender,
nodeManager,
analytics,
+ packetRepository,
+ workManager,
)
}
@@ -194,10 +204,23 @@ class MeshConnectionManagerTest {
}
@Test
- fun `onRadioConfigLoaded processes queued packets and sets time`() = runTest(testDispatcher) {
- manager.onRadioConfigLoaded()
+ fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) {
+ manager.start(backgroundScope)
+ val packetId = 456
+ val dataPacket = mockk(relaxed = true)
+ every { dataPacket.id } returns packetId
+ coEvery { packetRepository.getQueuedPackets() } returns listOf(dataPacket)
- verify { commandSender.processQueuedPackets() }
+ manager.onRadioConfigLoaded()
+ advanceUntilIdle()
+
+ verify {
+ workManager.enqueueUniqueWork(
+ match { it.startsWith(SendMessageWorker.WORK_NAME_PREFIX) },
+ any(),
+ any(),
+ )
+ }
verify { commandSender.sendAdmin(any(), initFn = any()) }
}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
index 1e9db9ba9..88cee4a4b 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
@@ -24,7 +24,7 @@ import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
diff --git a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
index 0ad9629f2..bd3ddc0b9 100644
--- a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
@@ -30,7 +30,7 @@ import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
-import org.meshtastic.core.service.ConnectionState
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
similarity index 97%
rename from feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterStringTransformer.kt
rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
index 9aebea8a0..d91c02b7e 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterStringTransformer.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2026 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,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.messaging
+package org.meshtastic.core.common.util
/**
* This util class allows you to optimize the binary size of the transmitted text message strings. It replaces certain
@@ -24,7 +24,7 @@ package org.meshtastic.feature.messaging
* reduces the binary size of the transmitted message. The average transmitted message volume can then fit around
* ~140-145 characters instead of ~115-120
*/
-internal object HomoglyphCharacterStringTransformer {
+object HomoglyphCharacterStringTransformer {
/**
* Unicode characters from the basic cyrillic block (U+0400-U+04FF), each of which occupies 2 bytes
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
index 564c66515..6046c68b6 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
@@ -25,8 +25,9 @@ import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
/**
- * A helper class that manages a single [Job]. When a new job is launched, the previous one is cancelled. This is useful
- * for ensuring that only one operation of a certain type is running at a time.
+ * A helper class that manages a single [Job]. When a new job is launched, any previous job is cancelled. This is useful
+ * for ensuring that only the latest operation of a certain type is running at a time (e.g. for search or settings
+ * updates).
*/
class SequentialJob @Inject constructor() {
private val job = AtomicReference()
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
index 8ea4e70be..53729ce48 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
@@ -56,7 +56,7 @@ import javax.inject.Singleton
/** Repository for managing node-related data, including hardware info, node database, and identity. */
@Singleton
@Suppress("TooManyFunctions")
-class NodeRepository
+open class NodeRepository
@Inject
constructor(
@ProcessLifecycle private val processLifecycle: Lifecycle,
@@ -66,7 +66,7 @@ constructor(
private val localStatsDataSource: LocalStatsDataSource,
) {
/** Hardware info about our local device (can be null if not connected). */
- val myNodeInfo: StateFlow =
+ open val myNodeInfo: StateFlow =
nodeInfoReadDataSource
.myNodeInfoFlow()
.flowOn(dispatchers.io)
@@ -75,7 +75,7 @@ constructor(
private val _ourNodeInfo = MutableStateFlow(null)
/** Information about the locally connected node, as seen from the mesh. */
- val ourNodeInfo: StateFlow
+ open val ourNodeInfo: StateFlow
get() = _ourNodeInfo
private val _myId = MutableStateFlow(null)
@@ -131,7 +131,7 @@ constructor(
.map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum }
.distinctUntilChanged()
- fun getNodeDBbyNum() =
+ fun getNodeEntityDBbyNumFlow() =
nodeInfoReadDataSource.nodeDBbyNumFlow().map { map -> map.mapValues { (_, it) -> it.toEntity() } }
/** Returns the [Node] associated with a given [userId]. Falls back to a generic node if not found. */
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt
index a22b001e4..1e4067f80 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt
@@ -36,7 +36,7 @@ import javax.inject.Inject
* Class responsible for radio configuration data. Combines access to [nodeDB], [ChannelSet], [LocalConfig] &
* [LocalModuleConfig].
*/
-class RadioConfigRepository
+open class RadioConfigRepository
@Inject
constructor(
private val nodeDB: NodeRepository,
@@ -68,7 +68,7 @@ constructor(
suspend fun updateChannelSettings(channel: Channel) = channelSetDataSource.updateChannelSettings(channel)
/** Flow representing the [LocalConfig] data store. */
- val localConfigFlow: Flow = localConfigDataSource.localConfigFlow
+ open val localConfigFlow: Flow = localConfigDataSource.localConfigFlow
/** Clears the [LocalConfig] data in the data store. */
suspend fun clearLocalConfig() {
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index 3ae7d49f7..fe90c72e3 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -46,7 +46,12 @@ import javax.inject.Singleton
@Singleton
@Suppress("TooManyFunctions")
@OptIn(ExperimentalCoroutinesApi::class)
-class DatabaseManager @Inject constructor(private val app: Application, private val dispatchers: CoroutineDispatchers) {
+open class DatabaseManager
+@Inject
+constructor(
+ private val app: Application,
+ private val dispatchers: CoroutineDispatchers,
+) {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
@@ -54,7 +59,7 @@ class DatabaseManager @Inject constructor(private val app: Application, private
// Expose the DB cache limit as a reactive stream so UI can observe changes.
private val _cacheLimit = MutableStateFlow(getCacheLimit())
- val cacheLimit: StateFlow = _cacheLimit
+ open val cacheLimit: StateFlow = _cacheLimit
// Keep cache-limit StateFlow in sync if some other component updates SharedPreferences.
private val prefsListener =
diff --git a/core/di/build.gradle.kts b/core/di/build.gradle.kts
index ef82c29a4..d968dda63 100644
--- a/core/di/build.gradle.kts
+++ b/core/di/build.gradle.kts
@@ -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
@@ -40,4 +40,4 @@ plugins {
configure { namespace = "org.meshtastic.core.di" }
-dependencies {}
+dependencies { implementation(libs.androidx.work.runtime.ktx) }
diff --git a/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt b/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt
index 4c834d897..0dfe5764a 100644
--- a/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt
+++ b/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.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,17 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.di
+import android.content.Context
+import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
+import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@@ -30,4 +33,8 @@ object AppModule {
@Provides
fun provideCoroutineDispatchers(): CoroutineDispatchers =
CoroutineDispatchers(io = Dispatchers.IO, main = Dispatchers.Main, default = Dispatchers.Default)
+
+ @Provides
+ @Singleton
+ fun provideWorkManager(@ApplicationContext context: Context): WorkManager = WorkManager.getInstance(context)
}
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
new file mode 100644
index 000000000..60226b661
--- /dev/null
+++ b/core/domain/build.gradle.kts
@@ -0,0 +1,44 @@
+/*
+ * 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 .
+ */
+
+plugins {
+ alias(libs.plugins.meshtastic.android.library)
+ alias(libs.plugins.meshtastic.android.library.flavors)
+ alias(libs.plugins.meshtastic.hilt)
+}
+
+android { namespace = "org.meshtastic.core.domain" }
+
+dependencies {
+ implementation(projects.core.model)
+ implementation(projects.core.proto)
+ implementation(projects.core.common)
+ implementation(projects.core.database)
+ implementation(projects.core.prefs)
+ implementation(projects.core.data)
+ implementation(projects.core.datastore)
+ implementation(projects.core.resources)
+
+ implementation(libs.kermit)
+ implementation(libs.compose.multiplatform.resources)
+
+ testImplementation(libs.junit)
+ testImplementation(libs.mockk)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.turbine)
+ testImplementation(libs.kotlinx.coroutines.test)
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt
new file mode 100644
index 000000000..5142c89f9
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 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.domain
+
+/**
+ * Interface for enqueuing background work for transmitting messages. This allows the domain layer to trigger durable
+ * transmission without depending on Android-specific WorkManager.
+ */
+interface MessageQueue {
+ suspend fun enqueue(packetId: Int)
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt
similarity index 60%
rename from feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCase.kt
rename to core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt
index 1c9863015..ca2cf3f77 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCase.kt
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt
@@ -14,28 +14,39 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.messaging.domain.usecase
+package org.meshtastic.core.domain.usecase
import co.touchlab.kermit.Logger
+import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
+import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.MessageQueue
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
-import org.meshtastic.core.service.ServiceAction
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.feature.messaging.HomoglyphCharacterStringTransformer
import org.meshtastic.proto.Config
-import org.meshtastic.proto.SharedContact
import javax.inject.Inject
+import kotlin.math.abs
+import kotlin.random.Random
+/**
+ * Use case for sending a message. This component handles message transformation, persistence, and enqueuing for durable
+ * delivery.
+ */
@Suppress("TooGenericExceptionCaught")
class SendMessageUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
- private val serviceRepository: ServiceRepository,
+ private val packetRepository: PacketRepository,
+ private val radioController: RadioController,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
+ private val messageQueue: MessageQueue,
) {
@Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod")
@@ -74,18 +85,45 @@ constructor(
text
}
- val packet = DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply { from = fromId }
+ val packetId = abs(Random.nextInt())
+
+ val packet =
+ DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply {
+ from = fromId
+ id = packetId
+ status = MessageStatus.QUEUED
+ }
+
+ val packetToSave =
+ Packet(
+ uuid = 0L,
+ myNodeNum = ourNode?.num ?: 0,
+ packetId = packetId,
+ port_num = packet.dataType,
+ contact_key = contactKey,
+ received_time = nowMillis,
+ read = true,
+ data = packet,
+ snr = packet.snr,
+ rssi = packet.rssi,
+ hopsAway = packet.hopsAway,
+ filtered = false,
+ )
try {
- serviceRepository.meshService?.send(packet)
+ // Write to the DB to immediately reflect the queued state on the UI
+ packetRepository.insert(packetToSave)
+
+ // Enqueue for durable transmission via the platform-specific queue
+ messageQueue.enqueue(packetId)
} catch (ex: Exception) {
- Logger.e(ex) { "Failed to send data packet" }
+ Logger.e(ex) { "Failed to enqueue message packet" }
}
}
private suspend fun favoriteNode(node: Node) {
try {
- serviceRepository.onServiceAction(ServiceAction.Favorite(node))
+ radioController.favoriteNode(node.num)
} catch (ex: Exception) {
Logger.e(ex) { "Favorite node error" }
}
@@ -93,9 +131,7 @@ constructor(
private suspend fun sendSharedContact(node: Node) {
try {
- val contact =
- SharedContact(node_num = node.num, user = node.user, manually_verified = node.manuallyVerified)
- serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact))
+ radioController.sendSharedContact(node.num)
} catch (ex: Exception) {
Logger.e(ex) { "Send shared contact error" }
}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt
new file mode 100644
index 000000000..728a209e4
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.model.RadioController
+import javax.inject.Inject
+
+/** Use case for performing administrative actions on the radio. */
+open class AdminActionsUseCase
+@Inject
+constructor(
+ private val radioController: RadioController,
+ private val nodeRepository: NodeRepository,
+) {
+ /**
+ * Reboots the radio.
+ *
+ * @param destNum The node number to reboot.
+ * @return The packet ID of the request.
+ */
+ suspend fun reboot(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.reboot(destNum, packetId)
+ return packetId
+ }
+
+ /**
+ * Shuts down the radio.
+ *
+ * @param destNum The node number to shut down.
+ * @return The packet ID of the request.
+ */
+ suspend fun shutdown(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.shutdown(destNum, packetId)
+ return packetId
+ }
+
+ /**
+ * Factory resets the radio.
+ *
+ * @param destNum The node number to reset.
+ * @param isLocal Whether the reset is being performed on the locally connected node.
+ * @return The packet ID of the request.
+ */
+ suspend fun factoryReset(destNum: Int, isLocal: Boolean): Int {
+ val packetId = radioController.getPacketId()
+ radioController.factoryReset(destNum, packetId)
+
+ if (isLocal) {
+ // If it's the local node, we should also clear the phone's node database as it will be out of sync.
+ nodeRepository.clearNodeDB()
+ }
+
+ return packetId
+ }
+
+ /**
+ * Resets the NodeDB on the radio.
+ *
+ * @param destNum The node number to reset.
+ * @param preserveFavorites Whether to keep favorite nodes in the database.
+ * @param isLocal Whether the reset is being performed on the locally connected node.
+ * @return The packet ID of the request.
+ */
+ suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean, isLocal: Boolean): Int {
+ val packetId = radioController.getPacketId()
+ radioController.nodedbReset(destNum, packetId, preserveFavorites)
+
+ if (isLocal) {
+ // If it's the local node, we should also clear the phone's node database.
+ nodeRepository.clearNodeDB(preserveFavorites)
+ }
+
+ return packetId
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt
new file mode 100644
index 000000000..6a32f1131
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.RadioController
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.days
+
+/** Use case for cleaning up nodes from the database. */
+class CleanNodeDatabaseUseCase
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ private val radioController: RadioController,
+) {
+ /** Identifies nodes that match the cleanup criteria. */
+ suspend fun getNodesToClean(olderThanDays: Float, onlyUnknownNodes: Boolean, currentTimeSeconds: Long): List {
+ val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
+ val olderThanTimestamp = currentTimeSeconds - olderThanDays.toInt().days.inWholeSeconds
+
+ val nodesToConsider =
+ if (onlyUnknownNodes) {
+ val olderNodes = nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
+ val unknownNodes = nodeRepository.getUnknownNodes()
+ olderNodes.filter { itNode -> unknownNodes.any { it.num == itNode.num } }
+ } else {
+ nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
+ }
+
+ return nodesToConsider
+ .filterNot { node ->
+ (node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || node.isIgnored || node.isFavorite
+ }
+ .map { it.toModel() }
+ }
+
+ /** Performs the cleanup of specified nodes. */
+ suspend fun cleanNodes(nodeNums: List) {
+ if (nodeNums.isEmpty()) return
+
+ nodeRepository.deleteNodes(nodeNums)
+ val packetId = radioController.getPacketId()
+ for (nodeNum in nodeNums) {
+ radioController.removeByNodenum(packetId, nodeNum)
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
new file mode 100644
index 000000000..c8bcdf699
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
@@ -0,0 +1,122 @@
+/*
+ * 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.domain.usecase.settings
+
+import android.icu.text.SimpleDateFormat
+import kotlinx.coroutines.flow.first
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.util.positionToMeter
+import org.meshtastic.proto.PortNum
+import java.io.BufferedWriter
+import java.util.Locale
+import javax.inject.Inject
+import kotlin.math.roundToInt
+import org.meshtastic.proto.Position as ProtoPosition
+
+/** Use case for exporting persisted packet data to a CSV format. */
+class ExportDataUseCase
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ private val meshLogRepository: MeshLogRepository,
+) {
+ /**
+ * Writes all persisted packet data to the provided [BufferedWriter].
+ *
+ * @param writer The writer to output the CSV data to.
+ * @param myNodeNum The node number of the current device.
+ * @param filterPortnum If provided, only packets with this port number will be exported.
+ */
+ @Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod", "detekt:NestedBlockDepth")
+ suspend operator fun invoke(writer: BufferedWriter, myNodeNum: Int, filterPortnum: Int? = null) {
+ val nodes = nodeRepository.nodeDBbyNum.value
+ val positionToPos: (ProtoPosition?) -> Position? = { meshPosition ->
+ meshPosition?.let { Position(it) }?.takeIf { it.isValid() }
+ }
+
+ val nodePositions = mutableMapOf()
+
+ @Suppress("MaxLineLength")
+ writer.appendLine(
+ "\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance(m)\",\"hop limit\",\"payload\"",
+ )
+
+ val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
+ meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
+ packet.nodeInfo?.let { nodeInfo ->
+ positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
+ }
+
+ packet.meshPacket?.let { proto ->
+ packet.position?.let { position ->
+ positionToPos.invoke(position)?.let {
+ nodePositions[proto.from.takeIf { it != 0 } ?: myNodeNum] = position
+ }
+ }
+
+ if (
+ (filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
+ proto.rx_snr != 0.0f
+ ) {
+ val rxDateTime = dateFormat.format(packet.received_date)
+ val rxFrom = proto.from.toUInt()
+ val senderName = nodes[proto.from]?.user?.long_name ?: ""
+
+ val senderPosition = nodePositions[proto.from]
+ val senderPos = positionToPos.invoke(senderPosition)
+ val senderLat = senderPos?.latitude ?: ""
+ val senderLong = senderPos?.longitude ?: ""
+
+ val rxPosition = nodePositions[myNodeNum]
+ val rxPos = positionToPos.invoke(rxPosition)
+ val rxLat = rxPos?.latitude ?: ""
+ val rxLong = rxPos?.longitude ?: ""
+ val rxAlt = rxPos?.altitude ?: ""
+ val rxSnr = proto.rx_snr
+
+ val dist =
+ if (senderPos == null || rxPos == null) {
+ ""
+ } else {
+ positionToMeter(Position(rxPosition!!), Position(senderPosition!!)).roundToInt().toString()
+ }
+
+ val hopLimit = proto.hop_limit
+ val decoded = proto.decoded
+ val encrypted = proto.encrypted
+ val payload =
+ when {
+ (decoded?.portnum?.value ?: 0) !in
+ setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.RANGE_TEST_APP.value) ->
+ "<${decoded?.portnum}>"
+
+ decoded != null -> decoded.payload.utf8().replace("\"", "\"\"")
+ encrypted != null -> "${encrypted.size} encrypted bytes"
+ else -> ""
+ }
+
+ @Suppress("MaxLineLength")
+ writer.appendLine(
+ "$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt
new file mode 100644
index 000000000..8a9905975
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.proto.DeviceProfile
+import java.io.OutputStream
+import javax.inject.Inject
+
+/** Use case for exporting a device profile to an output stream. */
+class ExportProfileUseCase @Inject constructor() {
+ /**
+ * Exports the provided [DeviceProfile] to the given [OutputStream].
+ *
+ * @param outputStream The stream to write the profile to.
+ * @param profile The device profile to export.
+ * @return A [Result] indicating success or failure.
+ */
+ operator fun invoke(outputStream: OutputStream, profile: DeviceProfile): Result = runCatching {
+ outputStream.write(profile.encode())
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt
new file mode 100644
index 000000000..2e32ed868
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.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.domain.usecase.settings
+
+import android.util.Base64
+import org.json.JSONObject
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.proto.Config
+import java.io.OutputStream
+import javax.inject.Inject
+
+/** Use case for exporting security configuration to a JSON format. */
+class ExportSecurityConfigUseCase @Inject constructor() {
+ /**
+ * Exports the provided [Config.SecurityConfig] as a JSON string to the given [OutputStream].
+ *
+ * @param outputStream The stream to write the JSON to.
+ * @param securityConfig The security configuration to export.
+ * @return A [Result] indicating success or failure.
+ */
+ operator fun invoke(outputStream: OutputStream, securityConfig: Config.SecurityConfig): Result = runCatching {
+ val publicKeyBytes = securityConfig.public_key.toByteArray()
+ val privateKeyBytes = securityConfig.private_key.toByteArray()
+
+ // Convert byte arrays to Base64 strings
+ val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
+ val privateKeyBase64 = Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)
+
+ // Create a JSON object
+ val jsonObject =
+ JSONObject().apply {
+ put("timestamp", nowMillis)
+ put("public_key", publicKeyBase64)
+ put("private_key", privateKeyBase64)
+ }
+
+ val jsonString = jsonObject.toString(JSON_INDENT_SPACES)
+ outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
+ }
+
+ private companion object {
+ private const val JSON_INDENT_SPACES = 4
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt
new file mode 100644
index 000000000..7dc1a9745
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.proto.DeviceProfile
+import java.io.InputStream
+import javax.inject.Inject
+
+/** Use case for importing a device profile from an input stream. */
+class ImportProfileUseCase @Inject constructor() {
+ /**
+ * Imports a [DeviceProfile] from the provided [InputStream].
+ *
+ * @param inputStream The stream to read the profile from.
+ * @return A [Result] containing the imported [DeviceProfile] or an error.
+ */
+ operator fun invoke(inputStream: InputStream): Result = runCatching {
+ val bytes = inputStream.readBytes()
+ DeviceProfile.ADAPTER.decode(bytes)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
new file mode 100644
index 000000000..20b59f452
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
@@ -0,0 +1,153 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.DeviceProfile
+import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.User
+import javax.inject.Inject
+
+/** Use case for installing a device profile onto a radio. */
+class InstallProfileUseCase @Inject constructor(private val radioController: RadioController) {
+ /**
+ * Installs the provided [DeviceProfile] onto the radio at [destNum].
+ *
+ * @param destNum The destination node number.
+ * @param profile The device profile to install.
+ * @param currentUser The current user configuration of the destination node (to preserve names if not in profile).
+ */
+ suspend operator fun invoke(destNum: Int, profile: DeviceProfile, currentUser: User?) {
+ radioController.beginEditSettings(destNum)
+
+ installOwner(destNum, profile, currentUser)
+ installConfig(destNum, profile.config)
+ installFixedPosition(destNum, profile.fixed_position)
+ installModuleConfig(destNum, profile.module_config)
+
+ radioController.commitEditSettings(destNum)
+ }
+
+ private suspend fun installOwner(destNum: Int, profile: DeviceProfile, currentUser: User?) {
+ if (profile.long_name != null || profile.short_name != null) {
+ currentUser?.let {
+ val user =
+ it.copy(
+ long_name = profile.long_name ?: it.long_name,
+ short_name = profile.short_name ?: it.short_name,
+ )
+ radioController.setOwner(destNum, user, radioController.getPacketId())
+ }
+ }
+ }
+
+ private suspend fun installConfig(destNum: Int, config: LocalConfig?) {
+ config?.let { lc ->
+ lc.device?.let { radioController.setConfig(destNum, Config(device = it), radioController.getPacketId()) }
+ lc.position?.let {
+ radioController.setConfig(destNum, Config(position = it), radioController.getPacketId())
+ }
+ lc.power?.let { radioController.setConfig(destNum, Config(power = it), radioController.getPacketId()) }
+ lc.network?.let { radioController.setConfig(destNum, Config(network = it), radioController.getPacketId()) }
+ lc.display?.let { radioController.setConfig(destNum, Config(display = it), radioController.getPacketId()) }
+ lc.lora?.let { radioController.setConfig(destNum, Config(lora = it), radioController.getPacketId()) }
+ lc.bluetooth?.let {
+ radioController.setConfig(destNum, Config(bluetooth = it), radioController.getPacketId())
+ }
+ lc.security?.let {
+ radioController.setConfig(destNum, Config(security = it), radioController.getPacketId())
+ }
+ }
+ }
+
+ private suspend fun installFixedPosition(destNum: Int, fixedPosition: org.meshtastic.proto.Position?) {
+ if (fixedPosition != null) {
+ radioController.setFixedPosition(destNum, Position(fixedPosition))
+ }
+ }
+
+ private suspend fun installModuleConfig(destNum: Int, moduleConfig: LocalModuleConfig?) {
+ moduleConfig?.let { lmc ->
+ installModuleConfigPart1(destNum, lmc)
+ installModuleConfigPart2(destNum, lmc)
+ }
+ }
+
+ private suspend fun installModuleConfigPart1(destNum: Int, lmc: LocalModuleConfig) {
+ lmc.mqtt?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(mqtt = it), radioController.getPacketId())
+ }
+ lmc.serial?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(serial = it), radioController.getPacketId())
+ }
+ lmc.external_notification?.let {
+ radioController.setModuleConfig(
+ destNum,
+ ModuleConfig(external_notification = it),
+ radioController.getPacketId(),
+ )
+ }
+ lmc.store_forward?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(store_forward = it), radioController.getPacketId())
+ }
+ lmc.range_test?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(range_test = it), radioController.getPacketId())
+ }
+ lmc.telemetry?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(telemetry = it), radioController.getPacketId())
+ }
+ lmc.canned_message?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(canned_message = it), radioController.getPacketId())
+ }
+ lmc.audio?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(audio = it), radioController.getPacketId())
+ }
+ }
+
+ private suspend fun installModuleConfigPart2(destNum: Int, lmc: LocalModuleConfig) {
+ lmc.remote_hardware?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(remote_hardware = it), radioController.getPacketId())
+ }
+ lmc.neighbor_info?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(neighbor_info = it), radioController.getPacketId())
+ }
+ lmc.ambient_lighting?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(ambient_lighting = it), radioController.getPacketId())
+ }
+ lmc.detection_sensor?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(detection_sensor = it), radioController.getPacketId())
+ }
+ lmc.paxcounter?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(paxcounter = it), radioController.getPacketId())
+ }
+ lmc.statusmessage?.let {
+ radioController.setModuleConfig(destNum, ModuleConfig(statusmessage = it), radioController.getPacketId())
+ }
+ lmc.traffic_management?.let {
+ radioController.setModuleConfig(
+ destNum,
+ ModuleConfig(traffic_management = it),
+ radioController.getPacketId(),
+ )
+ }
+ lmc.tak?.let { radioController.setModuleConfig(destNum, ModuleConfig(tak = it), radioController.getPacketId()) }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt
new file mode 100644
index 000000000..0e18a33a7
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.domain.usecase.settings
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import org.meshtastic.core.data.repository.DeviceHardwareRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.prefs.radio.RadioPrefs
+import org.meshtastic.core.prefs.radio.isBle
+import org.meshtastic.core.prefs.radio.isSerial
+import org.meshtastic.core.prefs.radio.isTcp
+import javax.inject.Inject
+
+/** Use case to determine if the currently connected device is capable of over-the-air (OTA) updates. */
+class IsOtaCapableUseCase
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ private val radioController: RadioController,
+ private val radioPrefs: RadioPrefs,
+ private val deviceHardwareRepository: DeviceHardwareRepository,
+) {
+ operator fun invoke(): Flow = combine(nodeRepository.ourNodeInfo, radioController.connectionState) {
+ node: Node?,
+ connectionState: ConnectionState,
+ ->
+ node to connectionState
+ }
+ .flatMapLatest { (node, connectionState) ->
+ if (node == null || connectionState != ConnectionState.Connected) {
+ flowOf(false)
+ } else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
+ val hwModel = node.user.hw_model.value
+ val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
+
+ // ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial.
+ // TODO: Re-enable when supportsUnifiedOta is added to DeviceHardware
+ val isEsp32OtaSupported = false
+
+ flowOf(hw?.requiresDfu == true || isEsp32OtaSupported)
+ } else {
+ flowOf(false)
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt
new file mode 100644
index 000000000..f03f89e23
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.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.domain.usecase.settings
+
+import org.meshtastic.core.model.RadioController
+import javax.inject.Inject
+
+/** Use case for controlling location sharing with the mesh. */
+class MeshLocationUseCase @Inject constructor(private val radioController: RadioController) {
+ /** Starts providing the phone's location to the mesh. */
+ fun startProvidingLocation() {
+ radioController.startProvideLocation()
+ }
+
+ /** Stops providing the phone's location to the mesh. */
+ fun stopProvidingLocation() {
+ radioController.stopProvideLocation()
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
new file mode 100644
index 000000000..e208a5435
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
@@ -0,0 +1,127 @@
+/*
+ * 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.domain.usecase.settings
+
+import co.touchlab.kermit.Logger
+import org.meshtastic.core.database.model.getStringResFrom
+import org.meshtastic.core.resources.UiText
+import org.meshtastic.proto.AdminMessage
+import org.meshtastic.proto.Channel
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.DeviceConnectionStatus
+import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.Routing
+import org.meshtastic.proto.User
+import javax.inject.Inject
+
+/** Sealed class representing the result of processing a radio response packet. */
+sealed class RadioResponseResult {
+ data class Metadata(val metadata: DeviceMetadata) : RadioResponseResult()
+
+ data class ChannelResponse(val channel: Channel) : RadioResponseResult()
+
+ data class Owner(val user: User) : RadioResponseResult()
+
+ data class ConfigResponse(val config: org.meshtastic.proto.Config) : RadioResponseResult()
+
+ data class ModuleConfigResponse(val config: org.meshtastic.proto.ModuleConfig) : RadioResponseResult()
+
+ data class CannedMessages(val messages: String) : RadioResponseResult()
+
+ data class Ringtone(val ringtone: String) : RadioResponseResult()
+
+ data class ConnectionStatus(val status: DeviceConnectionStatus) : RadioResponseResult()
+
+ data class Error(val message: UiText) : RadioResponseResult()
+
+ data object Success : RadioResponseResult()
+}
+
+/** Use case for processing incoming [MeshPacket]s that are responses to admin requests. */
+class ProcessRadioResponseUseCase @Inject constructor() {
+ /**
+ * Decodes and processes the provided [packet].
+ *
+ * @param packet The mesh packet received from the radio.
+ * @param destNum The node number that the response is expected from.
+ * @param requestIds The set of active request IDs.
+ * @return A [RadioResponseResult] if the packet matches a request, or null otherwise.
+ */
+ @Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
+ operator fun invoke(packet: MeshPacket, destNum: Int, requestIds: Set): RadioResponseResult? {
+ val data = packet.decoded
+ if (data == null || data.request_id !in requestIds) {
+ return null
+ }
+
+ return when (data.portnum) {
+ PortNum.ROUTING_APP -> processRoutingResponse(packet, data, destNum)
+ PortNum.ADMIN_APP -> processAdminResponse(packet, data, destNum)
+ else -> null
+ }
+ }
+
+ private fun processRoutingResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult? {
+ val parsed = Routing.ADAPTER.decode(data.payload)
+ return when {
+ parsed.error_reason != Routing.Error.NONE ->
+ RadioResponseResult.Error(UiText.Resource(getStringResFrom(parsed.error_reason?.value ?: 0)))
+ packet.from == destNum -> RadioResponseResult.Success
+ else -> null
+ }
+ }
+
+ private fun processAdminResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult {
+ if (destNum != packet.from) {
+ return RadioResponseResult.Error(
+ UiText.DynamicString("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}."),
+ )
+ }
+
+ val parsed = AdminMessage.ADAPTER.decode(data.payload)
+ return processAdminMessage(parsed)
+ }
+
+ private fun processAdminMessage(parsed: AdminMessage): RadioResponseResult = when {
+ parsed.get_device_metadata_response != null ->
+ RadioResponseResult.Metadata(parsed.get_device_metadata_response!!)
+
+ parsed.get_channel_response != null -> RadioResponseResult.ChannelResponse(parsed.get_channel_response!!)
+
+ parsed.get_owner_response != null -> RadioResponseResult.Owner(parsed.get_owner_response!!)
+
+ parsed.get_config_response != null -> RadioResponseResult.ConfigResponse(parsed.get_config_response!!)
+
+ parsed.get_module_config_response != null ->
+ RadioResponseResult.ModuleConfigResponse(parsed.get_module_config_response!!)
+
+ parsed.get_canned_message_module_messages_response != null ->
+ RadioResponseResult.CannedMessages(parsed.get_canned_message_module_messages_response!!)
+
+ parsed.get_ringtone_response != null -> RadioResponseResult.Ringtone(parsed.get_ringtone_response!!)
+
+ parsed.get_device_connection_status_response != null ->
+ RadioResponseResult.ConnectionStatus(parsed.get_device_connection_status_response!!)
+
+ else -> {
+ Logger.d { "No custom processing needed for $parsed" }
+ RadioResponseResult.Success
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt
new file mode 100644
index 000000000..a65b75209
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.User
+import javax.inject.Inject
+
+/** Use case for interacting with radio configuration components. */
+@Suppress("TooManyFunctions")
+open class RadioConfigUseCase @Inject constructor(private val radioController: RadioController) {
+ /**
+ * Updates the owner information on the radio.
+ *
+ * @param destNum The node number to update.
+ * @param user The new user configuration.
+ * @return The packet ID of the request.
+ */
+ suspend fun setOwner(destNum: Int, user: User): Int {
+ val packetId = radioController.getPacketId()
+ radioController.setOwner(destNum, user, packetId)
+ return packetId
+ }
+
+ /**
+ * Requests the owner information from the radio.
+ *
+ * @param destNum The node number to query.
+ * @return The packet ID of the request.
+ */
+ suspend fun getOwner(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getOwner(destNum, packetId)
+ return packetId
+ }
+
+ /**
+ * Updates a configuration section on the radio.
+ *
+ * @param destNum The node number to update.
+ * @param config The new configuration.
+ * @return The packet ID of the request.
+ */
+ suspend fun setConfig(destNum: Int, config: Config): Int {
+ val packetId = radioController.getPacketId()
+ radioController.setConfig(destNum, config, packetId)
+ return packetId
+ }
+
+ /**
+ * Requests a configuration section from the radio.
+ *
+ * @param destNum The node number to query.
+ * @param configType The type of configuration to request (from [org.meshtastic.proto.AdminMessage.ConfigType]).
+ * @return The packet ID of the request.
+ */
+ suspend fun getConfig(destNum: Int, configType: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getConfig(destNum, configType, packetId)
+ return packetId
+ }
+
+ /**
+ * Updates a module configuration section on the radio.
+ *
+ * @param destNum The node number to update.
+ * @param config The new module configuration.
+ * @return The packet ID of the request.
+ */
+ suspend fun setModuleConfig(destNum: Int, config: ModuleConfig): Int {
+ val packetId = radioController.getPacketId()
+ radioController.setModuleConfig(destNum, config, packetId)
+ return packetId
+ }
+
+ /**
+ * Requests a module configuration section from the radio.
+ *
+ * @param destNum The node number to query.
+ * @param moduleConfigType The type of module configuration to request.
+ * @return The packet ID of the request.
+ */
+ suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getModuleConfig(destNum, moduleConfigType, packetId)
+ return packetId
+ }
+
+ /**
+ * Requests a channel from the radio.
+ *
+ * @param destNum The node number to query.
+ * @param index The index of the channel to request.
+ * @return The packet ID of the request.
+ */
+ suspend fun getChannel(destNum: Int, index: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getChannel(destNum, index, packetId)
+ return packetId
+ }
+
+ /**
+ * Updates a channel on the radio.
+ *
+ * @param destNum The node number to update.
+ * @param channel The new channel configuration.
+ * @return The packet ID of the request.
+ */
+ suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel): Int {
+ val packetId = radioController.getPacketId()
+ radioController.setRemoteChannel(destNum, channel, packetId)
+ return packetId
+ }
+
+ /** Updates the fixed position on the radio. */
+ suspend fun setFixedPosition(destNum: Int, position: Position) {
+ radioController.setFixedPosition(destNum, position)
+ }
+
+ /** Removes the fixed position on the radio. */
+ suspend fun removeFixedPosition(destNum: Int) {
+ radioController.setFixedPosition(destNum, Position(0.0, 0.0, 0))
+ }
+
+ /** Sets the ringtone on the radio. */
+ suspend fun setRingtone(destNum: Int, ringtone: String) {
+ radioController.setRingtone(destNum, ringtone)
+ }
+
+ /**
+ * Requests the ringtone from the radio.
+ *
+ * @param destNum The node number to query.
+ * @return The packet ID of the request.
+ */
+ suspend fun getRingtone(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getRingtone(destNum, packetId)
+ return packetId
+ }
+
+ /** Sets the canned messages on the radio. */
+ suspend fun setCannedMessages(destNum: Int, messages: String) {
+ radioController.setCannedMessages(destNum, messages)
+ }
+
+ /**
+ * Requests the canned messages from the radio.
+ *
+ * @param destNum The node number to query.
+ * @return The packet ID of the request.
+ */
+ suspend fun getCannedMessages(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getCannedMessages(destNum, packetId)
+ return packetId
+ }
+
+ /**
+ * Requests the device connection status from the radio.
+ *
+ * @param destNum The node number to query.
+ * @return The packet ID of the request.
+ */
+ suspend fun getDeviceConnectionStatus(destNum: Int): Int {
+ val packetId = radioController.getPacketId()
+ radioController.getDeviceConnectionStatus(destNum, packetId)
+ return packetId
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt
new file mode 100644
index 000000000..04462c0f9
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.datastore.UiPreferencesDataSource
+import javax.inject.Inject
+
+/** Use case for setting whether the application intro has been completed. */
+class SetAppIntroCompletedUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
+ operator fun invoke(completed: Boolean) {
+ uiPreferencesDataSource.setAppIntroCompleted(completed)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt
new file mode 100644
index 000000000..4153ad934
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt
@@ -0,0 +1,29 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.database.DatabaseConstants
+import org.meshtastic.core.database.DatabaseManager
+import javax.inject.Inject
+
+/** Use case for setting the database cache limit. */
+class SetDatabaseCacheLimitUseCase @Inject constructor(private val databaseManager: DatabaseManager) {
+ operator fun invoke(limit: Int) {
+ val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
+ databaseManager.setCacheLimit(clamped)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt
new file mode 100644
index 000000000..360c72bcd
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import javax.inject.Inject
+
+/** Use case for managing mesh log settings. */
+class SetMeshLogSettingsUseCase
+@Inject
+constructor(
+ private val meshLogRepository: MeshLogRepository,
+ private val meshLogPrefs: MeshLogPrefs,
+) {
+ /**
+ * Sets the retention period for mesh logs.
+ *
+ * @param days The number of days to retain logs.
+ */
+ suspend fun setRetentionDays(days: Int) {
+ val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
+ meshLogPrefs.retentionDays = clamped
+ meshLogRepository.deleteLogsOlderThan(clamped)
+ }
+
+ /**
+ * Enables or disables mesh logging.
+ *
+ * @param enabled True to enable logging, false to disable.
+ */
+ suspend fun setLoggingEnabled(enabled: Boolean) {
+ meshLogPrefs.loggingEnabled = enabled
+ if (!enabled) {
+ meshLogRepository.deleteAll()
+ } else {
+ meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays)
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt
new file mode 100644
index 000000000..fa8daee9e
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.prefs.ui.UiPrefs
+import javax.inject.Inject
+
+/** Use case for setting whether to provide the node location to the mesh. */
+class SetProvideLocationUseCase @Inject constructor(private val uiPrefs: UiPrefs) {
+ operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
+ uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt
new file mode 100644
index 000000000..437e39604
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.datastore.UiPreferencesDataSource
+import javax.inject.Inject
+
+/** Use case for setting the application theme. */
+class SetThemeUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
+ operator fun invoke(themeMode: Int) {
+ uiPreferencesDataSource.setTheme(themeMode)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt
new file mode 100644
index 000000000..0682c4da2
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
+import javax.inject.Inject
+
+/** Use case for toggling the analytics preference. */
+class ToggleAnalyticsUseCase @Inject constructor(private val analyticsPrefs: AnalyticsPrefs) {
+ operator fun invoke() {
+ analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt
new file mode 100644
index 000000000..1c83d6886
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt
@@ -0,0 +1,27 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
+import javax.inject.Inject
+
+/** Use case for toggling the homoglyph encoding preference. */
+class ToggleHomoglyphEncodingUseCase @Inject constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
+ operator fun invoke() {
+ homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt
new file mode 100644
index 000000000..69ec2022a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 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.domain
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.ClientNotification
+
+class FakeRadioController : RadioController {
+
+ // Mutable state flows so we can manipulate them in our tests
+ private val _connectionState = MutableStateFlow(ConnectionState.Connected)
+ override val connectionState: StateFlow = _connectionState
+
+ private val _clientNotification = MutableStateFlow(null)
+ override val clientNotification: StateFlow = _clientNotification
+
+ // Track sent packets to assert in tests
+ val sentPackets = mutableListOf()
+ val favoritedNodes = mutableListOf()
+ val sentSharedContacts = mutableListOf()
+
+ override suspend fun sendMessage(packet: DataPacket) {
+ sentPackets.add(packet)
+ }
+
+ override fun clearClientNotification() {
+ _clientNotification.value = null
+ }
+
+ override suspend fun favoriteNode(nodeNum: Int) {
+ favoritedNodes.add(nodeNum)
+ }
+
+ override suspend fun sendSharedContact(nodeNum: Int) {
+ sentSharedContacts.add(nodeNum)
+ }
+
+ override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {}
+
+ override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {}
+
+ override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {}
+
+ override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {}
+
+ override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {}
+
+ override suspend fun setRingtone(destNum: Int, ringtone: String) {}
+
+ override suspend fun setCannedMessages(destNum: Int, messages: String) {}
+
+ override suspend fun getOwner(destNum: Int, packetId: Int) {}
+
+ override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {}
+
+ override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {}
+
+ override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {}
+
+ override suspend fun getRingtone(destNum: Int, packetId: Int) {}
+
+ override suspend fun getCannedMessages(destNum: Int, packetId: Int) {}
+
+ override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {}
+
+ override suspend fun reboot(destNum: Int, packetId: Int) {}
+
+ override suspend fun shutdown(destNum: Int, packetId: Int) {}
+
+ override suspend fun factoryReset(destNum: Int, packetId: Int) {}
+
+ override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {}
+
+ override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {}
+
+ override suspend fun beginEditSettings(destNum: Int) {}
+
+ override suspend fun commitEditSettings(destNum: Int) {}
+
+ override fun getPacketId(): Int = 1
+
+ override fun startProvideLocation() {}
+
+ override fun stopProvideLocation() {}
+
+ // --- Helper methods for testing ---
+
+ fun setConnectionState(state: ConnectionState) {
+ _connectionState.value = state
+ }
+}
diff --git a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
similarity index 69%
rename from feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCaseTest.kt
rename to core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
index 42adf05a8..6c0d0fe6e 100644
--- a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCaseTest.kt
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
@@ -14,49 +14,67 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.messaging.domain.usecase
+package org.meshtastic.core.domain.usecase
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
+import io.mockk.slot
+import io.mockk.unmockkAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.FakeRadioController
+import org.meshtastic.core.domain.MessageQueue
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
-import org.meshtastic.core.service.ServiceAction
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
class SendMessageUseCaseTest {
private lateinit var nodeRepository: NodeRepository
- private lateinit var serviceRepository: ServiceRepository
+ private lateinit var packetRepository: PacketRepository
+ private lateinit var radioController: FakeRadioController
private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
+ private lateinit var messageQueue: MessageQueue
private lateinit var useCase: SendMessageUseCase
@Before
fun setUp() {
nodeRepository = mockk(relaxed = true)
- serviceRepository = mockk(relaxed = true)
+ packetRepository = mockk(relaxed = true)
+ radioController = FakeRadioController()
homoglyphEncodingPrefs = mockk(relaxed = true)
+ messageQueue = mockk(relaxed = true)
useCase =
SendMessageUseCase(
nodeRepository = nodeRepository,
- serviceRepository = serviceRepository,
+ packetRepository = packetRepository,
+ radioController = radioController,
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
+ messageQueue = messageQueue,
)
mockkConstructor(Capabilities::class)
}
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
@Test
fun `invoke with broadcast message simply sends data packet`() = runTest {
// Arrange
@@ -69,8 +87,11 @@ class SendMessageUseCaseTest {
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
// Assert
- coVerify(exactly = 0) { serviceRepository.onServiceAction(any()) }
- coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
+ assertEquals(0, radioController.favoritedNodes.size)
+ assertEquals(0, radioController.sentSharedContacts.size)
+
+ coVerify { packetRepository.insert(any()) }
+ coVerify { messageQueue.enqueue(any()) }
}
@Test
@@ -86,18 +107,21 @@ class SendMessageUseCaseTest {
val destNode = mockk(relaxed = true)
every { destNode.isFavorite } returns false
+ every { destNode.num } returns 12345
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
-
every { anyConstructed().canSendVerifiedContacts } returns false
// Act
useCase("Direct message", "!dest", null)
// Assert
- coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.Favorite }) }
- coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
+ assertEquals(1, radioController.favoritedNodes.size)
+ assertEquals(12345, radioController.favoritedNodes[0])
+
+ coVerify { packetRepository.insert(any()) }
+ coVerify { messageQueue.enqueue(any()) }
}
@Test
@@ -112,18 +136,21 @@ class SendMessageUseCaseTest {
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
val destNode = mockk(relaxed = true)
+ every { destNode.num } returns 67890
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
-
every { anyConstructed().canSendVerifiedContacts } returns true
// Act
useCase("Direct message", "!dest", null)
// Assert
- coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.SendContact }) }
- coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
+ assertEquals(1, radioController.sentSharedContacts.size)
+ assertEquals(67890, radioController.sentSharedContacts[0])
+
+ coVerify { packetRepository.insert(any()) }
+ coVerify { messageQueue.enqueue(any()) }
}
@Test
@@ -133,14 +160,15 @@ class SendMessageUseCaseTest {
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
- // Let's use a cyrillic character 'A' (U+0410) that will be mapped to Latin 'A'
- val originalText = "\u0410pple"
+ val originalText = "\u0410pple" // Cyrillic A
// Act
useCase(originalText, "0${DataPacket.ID_BROADCAST}", null)
// Assert
- // We verify that send was called with the transformed text (Latin 'A'pple)
- coVerify(exactly = 1) { serviceRepository.meshService?.send(match { it.text?.contains("Apple") == true }) }
+ val packetSlot = slot()
+ coVerify { packetRepository.insert(capture(packetSlot)) }
+ assertTrue(packetSlot.captured.data?.text?.contains("Apple") == true)
+ coVerify { messageQueue.enqueue(any()) }
}
}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt
new file mode 100644
index 000000000..e423ca882
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt
@@ -0,0 +1,72 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.model.RadioController
+
+class AdminActionsUseCaseTest {
+
+ private lateinit var radioController: RadioController
+ private lateinit var nodeRepository: NodeRepository
+ private lateinit var useCase: AdminActionsUseCase
+
+ @Before
+ fun setUp() {
+ radioController = mockk(relaxed = true)
+ nodeRepository = mockk(relaxed = true)
+ useCase = AdminActionsUseCase(radioController, nodeRepository)
+ every { radioController.getPacketId() } returns 42
+ }
+
+ @Test
+ fun `reboot calls radioController and returns packetId`() = runTest {
+ val result = useCase.reboot(123)
+ coVerify { radioController.reboot(123, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `shutdown calls radioController and returns packetId`() = runTest {
+ val result = useCase.shutdown(123)
+ coVerify { radioController.shutdown(123, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `factoryReset calls radioController and clears DB if local`() = runTest {
+ val result = useCase.factoryReset(123, isLocal = true)
+ coVerify { radioController.factoryReset(123, 42) }
+ coVerify { nodeRepository.clearNodeDB() }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `nodedbReset calls radioController and clears DB if local`() = runTest {
+ val result = useCase.nodedbReset(123, preserveFavorites = true, isLocal = true)
+ coVerify { radioController.nodedbReset(123, 42, true) }
+ coVerify { nodeRepository.clearNodeDB(true) }
+ assertEquals(42, result)
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt
new file mode 100644
index 000000000..001c0a5fe
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.domain.FakeRadioController
+import kotlin.time.Duration.Companion.days
+
+class CleanNodeDatabaseUseCaseTest {
+
+ private lateinit var nodeRepository: NodeRepository
+ private lateinit var radioController: FakeRadioController
+ private lateinit var useCase: CleanNodeDatabaseUseCase
+
+ @Before
+ fun setUp() {
+ nodeRepository = mockk(relaxed = true)
+ radioController = FakeRadioController()
+ useCase = CleanNodeDatabaseUseCase(nodeRepository, radioController)
+ }
+
+ @Test
+ fun `getNodesToClean filters nodes correctly`() = runTest {
+ // Arrange
+ val currentTime = 1000000L
+ val olderThanTimestamp = currentTime - 30.days.inWholeSeconds
+
+ val oldNode = NodeEntity(num = 1, lastHeard = (olderThanTimestamp - 1).toInt())
+ val newNode = NodeEntity(num = 2, lastHeard = (currentTime - 1).toInt())
+ val ignoredNode = NodeEntity(num = 3, lastHeard = (olderThanTimestamp - 1).toInt(), isIgnored = true)
+
+ coEvery { nodeRepository.getNodesOlderThan(any()) } returns listOf(oldNode, ignoredNode)
+
+ // Act
+ val result = useCase.getNodesToClean(30f, false, currentTime)
+
+ // Assert
+ assertEquals(1, result.size)
+ assertEquals(1, result[0].num)
+ }
+
+ @Test
+ fun `cleanNodes calls repository and controller`() = runTest {
+ // Act
+ useCase.cleanNodes(listOf(1, 2))
+
+ // Assert
+ coVerify { nodeRepository.deleteNodes(listOf(1, 2)) }
+ // Note: we can't easily verify removeByNodenum on FakeRadioController without adding tracking
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt
new file mode 100644
index 000000000..32dcff37f
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 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.domain.usecase.settings
+
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import okio.ByteString.Companion.encodeUtf8
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.entity.MeshLog
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.FromRadio
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.User
+import org.robolectric.RobolectricTestRunner
+import java.io.BufferedWriter
+import java.io.StringWriter
+
+@RunWith(RobolectricTestRunner::class)
+class ExportDataUseCaseTest {
+
+ private lateinit var nodeRepository: NodeRepository
+ private lateinit var meshLogRepository: MeshLogRepository
+ private lateinit var useCase: ExportDataUseCase
+
+ @Before
+ fun setUp() {
+ nodeRepository = mockk(relaxed = true)
+ meshLogRepository = mockk(relaxed = true)
+ useCase = ExportDataUseCase(nodeRepository, meshLogRepository)
+ }
+
+ @Test
+ fun `invoke writes header and log data`() = runTest {
+ // Arrange
+ val myNodeNum = 123
+ val senderNodeNum = 456
+ val senderNode = Node(num = senderNodeNum, user = User(long_name = "Sender Name"))
+
+ val nodes = mapOf(senderNodeNum to senderNode)
+ val stateFlow = MutableStateFlow(nodes)
+ every { nodeRepository.nodeDBbyNum } returns stateFlow
+ every { nodeRepository.getNodeEntityDBbyNumFlow() } returns flowOf(emptyMap())
+
+ val meshPacket =
+ MeshPacket(
+ from = senderNodeNum,
+ rx_snr = 5.5f,
+ decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "Hello".encodeUtf8()),
+ )
+ val meshLog =
+ MeshLog(
+ uuid = "uuid-1",
+ message_type = "Packet",
+ received_date = 1700000000000L,
+ raw_message = "",
+ fromNum = senderNodeNum,
+ portNum = PortNum.TEXT_MESSAGE_APP.value,
+ fromRadio = FromRadio(packet = meshPacket),
+ )
+ every { meshLogRepository.getAllLogsInReceiveOrder(any()) } returns flowOf(listOf(meshLog))
+
+ val stringWriter = StringWriter()
+ val bufferedWriter = BufferedWriter(stringWriter)
+
+ // Act
+ useCase(bufferedWriter, myNodeNum)
+ bufferedWriter.flush()
+
+ // Assert
+ val output = stringWriter.toString()
+ assertTrue("Header should be present", output.contains("\"date\",\"time\",\"from\",\"sender name\""))
+ assertTrue("Sender name should be present", output.contains("Sender Name"))
+ assertTrue("Payload should be present", output.contains("Hello"))
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt
new file mode 100644
index 000000000..e2e26f4f2
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.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.domain.usecase.settings
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.proto.DeviceProfile
+import java.io.ByteArrayOutputStream
+
+class ExportProfileUseCaseTest {
+
+ private lateinit var useCase: ExportProfileUseCase
+
+ @Before
+ fun setUp() {
+ useCase = ExportProfileUseCase()
+ }
+
+ @Test
+ fun `invoke writes encoded profile to output stream`() {
+ // Arrange
+ val profile = DeviceProfile(long_name = "Export Node")
+ val outputStream = ByteArrayOutputStream()
+
+ // Act
+ val result = useCase(outputStream, profile)
+
+ // Assert
+ assertTrue(result.isSuccess)
+ assertArrayEquals(profile.encode(), outputStream.toByteArray())
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt
new file mode 100644
index 000000000..b86569cd0
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.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.domain.usecase.settings
+
+import okio.ByteString.Companion.toByteString
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.proto.Config
+import org.robolectric.RobolectricTestRunner
+import java.io.ByteArrayOutputStream
+
+@RunWith(RobolectricTestRunner::class)
+class ExportSecurityConfigUseCaseTest {
+
+ private lateinit var useCase: ExportSecurityConfigUseCase
+
+ @Before
+ fun setUp() {
+ useCase = ExportSecurityConfigUseCase()
+ }
+
+ @Test
+ fun `invoke writes valid JSON to output stream`() {
+ // Arrange
+ val publicKey = byteArrayOf(1, 2, 3).toByteString()
+ val privateKey = byteArrayOf(4, 5, 6).toByteString()
+ val config = Config.SecurityConfig(public_key = publicKey, private_key = privateKey)
+ val outputStream = ByteArrayOutputStream()
+
+ // Act
+ val result = useCase(outputStream, config)
+
+ // Assert
+ assertTrue(result.isSuccess)
+ val json = JSONObject(outputStream.toString())
+ assertTrue(json.has("timestamp"))
+ assertTrue(json.has("public_key"))
+ assertTrue(json.has("private_key"))
+ // Check base64 values
+ assertEquals("AQID", json.getString("public_key"))
+ assertEquals("BAUG", json.getString("private_key"))
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt
new file mode 100644
index 000000000..7b41a67f8
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.proto.DeviceProfile
+import java.io.ByteArrayInputStream
+
+class ImportProfileUseCaseTest {
+
+ private lateinit var useCase: ImportProfileUseCase
+
+ @Before
+ fun setUp() {
+ useCase = ImportProfileUseCase()
+ }
+
+ @Test
+ fun `invoke with valid data returns profile`() {
+ // Arrange
+ val profile = DeviceProfile(long_name = "Test Node")
+ val inputStream = ByteArrayInputStream(profile.encode())
+
+ // Act
+ val result = useCase(inputStream)
+
+ // Assert
+ assertTrue(result.isSuccess)
+ assertEquals("Test Node", result.getOrNull()?.long_name)
+ }
+
+ @Test
+ fun `invoke with invalid data returns failure`() {
+ // Arrange
+ val inputStream = ByteArrayInputStream(byteArrayOf(1, 2, 3))
+
+ // Act
+ val result = useCase(inputStream)
+
+ // Assert
+ assertTrue(result.isFailure)
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
new file mode 100644
index 000000000..411d47a92
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 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.domain.usecase.settings
+
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.DeviceProfile
+import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.User
+
+class InstallProfileUseCaseTest {
+
+ private lateinit var radioController: RadioController
+ private lateinit var useCase: InstallProfileUseCase
+
+ @Before
+ fun setUp() {
+ radioController = mockk(relaxed = true)
+ useCase = InstallProfileUseCase(radioController)
+ every { radioController.getPacketId() } returns 1
+ }
+
+ @Test
+ fun `invoke with names updates owner`() = runTest {
+ // Arrange
+ val profile = DeviceProfile(long_name = "New Long", short_name = "NL")
+ val currentUser = User(long_name = "Old Long", short_name = "OL")
+
+ // Act
+ useCase(123, profile, currentUser)
+
+ // Assert
+ coVerify { radioController.beginEditSettings(123) }
+ coVerify { radioController.setOwner(123, match { it.long_name == "New Long" && it.short_name == "NL" }, 1) }
+ coVerify { radioController.commitEditSettings(123) }
+ }
+
+ @Test
+ fun `invoke with config sets config`() = runTest {
+ // Arrange
+ val loraConfig = Config.LoRaConfig(region = Config.LoRaConfig.RegionCode.US)
+ val profile = DeviceProfile(config = LocalConfig(lora = loraConfig))
+
+ // Act
+ useCase(456, profile, null)
+
+ // Assert
+ coVerify { radioController.setConfig(456, match { it.lora == loraConfig }, 1) }
+ }
+
+ @Test
+ fun `invoke with module_config sets module config`() = runTest {
+ // Arrange
+ val mqttConfig = ModuleConfig.MQTTConfig(enabled = true, address = "broker.local")
+ val profile = DeviceProfile(module_config = LocalModuleConfig(mqtt = mqttConfig))
+
+ // Act
+ useCase(789, profile, null)
+
+ // Assert
+ coVerify { radioController.setModuleConfig(789, match { it.mqtt == mqttConfig }, 1) }
+ }
+
+ @Test
+ fun `invoke with module_config part 2 sets module config`() = runTest {
+ // Arrange
+ val neighborInfoConfig = ModuleConfig.NeighborInfoConfig(enabled = true)
+ val profile = DeviceProfile(module_config = LocalModuleConfig(neighbor_info = neighborInfoConfig))
+
+ // Act
+ useCase(789, profile, null)
+
+ // Assert
+ coVerify { radioController.setModuleConfig(789, match { it.neighbor_info == neighborInfoConfig }, 1) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt
new file mode 100644
index 000000000..41db758c7
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt
@@ -0,0 +1,124 @@
+/*
+ * 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.domain.usecase.settings
+
+import app.cash.turbine.test
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.DeviceHardwareRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.prefs.radio.RadioPrefs
+
+class IsOtaCapableUseCaseTest {
+
+ private lateinit var nodeRepository: NodeRepository
+ private lateinit var radioController: RadioController
+ private lateinit var radioPrefs: RadioPrefs
+ private lateinit var deviceHardwareRepository: DeviceHardwareRepository
+ private lateinit var useCase: IsOtaCapableUseCase
+
+ private val ourNodeInfoFlow = MutableStateFlow(null)
+ private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected)
+
+ @Before
+ fun setUp() {
+ nodeRepository = mockk { every { ourNodeInfo } returns ourNodeInfoFlow }
+ radioController = mockk { every { connectionState } returns connectionStateFlow }
+ radioPrefs = mockk(relaxed = true)
+ deviceHardwareRepository = mockk(relaxed = true)
+
+ useCase = IsOtaCapableUseCase(nodeRepository, radioController, radioPrefs, deviceHardwareRepository)
+ }
+
+ @Test
+ fun `returns false when node is null`() = runTest {
+ ourNodeInfoFlow.value = null
+ connectionStateFlow.value = ConnectionState.Connected
+
+ useCase().test {
+ assertFalse(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `returns false when not connected`() = runTest {
+ val node = mockk(relaxed = true)
+ ourNodeInfoFlow.value = node
+ connectionStateFlow.value = ConnectionState.Disconnected
+
+ useCase().test {
+ assertFalse(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `returns false when radio is not BLE, Serial, or TCP`() = runTest {
+ val node = mockk(relaxed = true)
+ ourNodeInfoFlow.value = node
+ connectionStateFlow.value = ConnectionState.Connected
+ every { radioPrefs.devAddr } returns "m123" // Mock
+
+ useCase().test {
+ assertFalse(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `returns true when hw requires Dfu`() = runTest {
+ val node = mockk(relaxed = true)
+ ourNodeInfoFlow.value = node
+ connectionStateFlow.value = ConnectionState.Connected
+ every { radioPrefs.devAddr } returns "x123" // BLE
+
+ val hw = mockk { every { requiresDfu } returns true }
+ coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
+
+ useCase().test {
+ assertTrue(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `returns false when hw does not require Dfu and isEsp32OtaSupported is false`() = runTest {
+ val node = mockk(relaxed = true)
+ ourNodeInfoFlow.value = node
+ connectionStateFlow.value = ConnectionState.Connected
+ every { radioPrefs.devAddr } returns "x123" // BLE
+
+ val hw = mockk { every { requiresDfu } returns false }
+ coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
+
+ useCase().test {
+ assertFalse(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt
new file mode 100644
index 000000000..95910cc78
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.model.RadioController
+
+class MeshLocationUseCaseTest {
+
+ private lateinit var radioController: RadioController
+ private lateinit var useCase: MeshLocationUseCase
+
+ @Before
+ fun setUp() {
+ radioController = mockk(relaxed = true)
+ useCase = MeshLocationUseCase(radioController)
+ }
+
+ @Test
+ fun `startProvidingLocation calls radioController`() {
+ useCase.startProvidingLocation()
+ verify { radioController.startProvideLocation() }
+ }
+
+ @Test
+ fun `stopProvidingLocation calls radioController`() {
+ useCase.stopProvidingLocation()
+ verify { radioController.stopProvideLocation() }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
new file mode 100644
index 000000000..9489a804e
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
@@ -0,0 +1,106 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.proto.AdminMessage
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.Routing
+
+class ProcessRadioResponseUseCaseTest {
+
+ private lateinit var useCase: ProcessRadioResponseUseCase
+
+ @Before
+ fun setUp() {
+ useCase = ProcessRadioResponseUseCase()
+ }
+
+ @Test
+ fun `invoke with routing error returns error result`() {
+ // Arrange
+ val packet =
+ MeshPacket(
+ from = 123,
+ decoded =
+ Data(
+ portnum = PortNum.ROUTING_APP,
+ request_id = 42,
+ payload = Routing(error_reason = Routing.Error.NO_ROUTE).encode().toByteString(),
+ ),
+ )
+
+ // Act
+ val result = useCase(packet, 123, setOf(42))
+
+ // Assert
+ assertTrue(result is RadioResponseResult.Error)
+ }
+
+ @Test
+ fun `invoke with metadata response returns metadata result`() {
+ // Arrange
+ val metadata = DeviceMetadata(firmware_version = "2.5.0")
+ val adminMsg = AdminMessage(get_device_metadata_response = metadata)
+ val packet =
+ MeshPacket(
+ from = 123,
+ decoded = Data(
+ portnum = PortNum.ADMIN_APP,
+ request_id = 42,
+ payload = adminMsg.encode().toByteString(),
+ ),
+ )
+
+ // Act
+ val result = useCase(packet, 123, setOf(42))
+
+ // Assert
+ assertTrue(result is RadioResponseResult.Metadata)
+ assertEquals("2.5.0", (result as RadioResponseResult.Metadata).metadata.firmware_version)
+ }
+
+ @Test
+ fun `invoke with canned messages response returns canned messages result`() {
+ // Arrange
+ val adminMsg = AdminMessage(get_canned_message_module_messages_response = "Hello World")
+ val packet =
+ MeshPacket(
+ from = 123,
+ decoded = Data(
+ portnum = PortNum.ADMIN_APP,
+ request_id = 42,
+ payload = adminMsg.encode().toByteString(),
+ ),
+ )
+
+ // Act
+ val result = useCase(packet, 123, setOf(42))
+
+ // Assert
+ assertTrue(result is RadioResponseResult.CannedMessages)
+ assertEquals("Hello World", (result as RadioResponseResult.CannedMessages).messages)
+ }
+
+ private fun ByteArray.toByteString() = okio.ByteString.of(*this)
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt
new file mode 100644
index 000000000..29e26406c
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt
@@ -0,0 +1,160 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.Channel
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.User
+
+class RadioConfigUseCaseTest {
+
+ private lateinit var radioController: RadioController
+ private lateinit var useCase: RadioConfigUseCase
+
+ @Before
+ fun setUp() {
+ radioController = mockk(relaxed = true)
+ useCase = RadioConfigUseCase(radioController)
+ every { radioController.getPacketId() } returns 42
+ }
+
+ @Test
+ fun `setOwner calls radioController and returns packetId`() = runTest {
+ val user = User(long_name = "New Name")
+ val result = useCase.setOwner(123, user)
+
+ coVerify { radioController.setOwner(123, user, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `getOwner calls radioController and returns packetId`() = runTest {
+ val result = useCase.getOwner(123)
+
+ coVerify { radioController.getOwner(123, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `setConfig calls radioController and returns packetId`() = runTest {
+ val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
+ val result = useCase.setConfig(123, config)
+
+ coVerify { radioController.setConfig(123, config, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `getConfig calls radioController and returns packetId`() = runTest {
+ val result = useCase.getConfig(123, 1)
+
+ coVerify { radioController.getConfig(123, 1, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `setModuleConfig calls radioController and returns packetId`() = runTest {
+ val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
+ val result = useCase.setModuleConfig(123, config)
+
+ coVerify { radioController.setModuleConfig(123, config, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `getModuleConfig calls radioController and returns packetId`() = runTest {
+ val result = useCase.getModuleConfig(123, 2)
+
+ coVerify { radioController.getModuleConfig(123, 2, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `getChannel calls radioController and returns packetId`() = runTest {
+ val result = useCase.getChannel(123, 0)
+
+ coVerify { radioController.getChannel(123, 0, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `setRemoteChannel calls radioController and returns packetId`() = runTest {
+ val channel = Channel(index = 0)
+ val result = useCase.setRemoteChannel(123, channel)
+
+ coVerify { radioController.setRemoteChannel(123, channel, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `setFixedPosition calls radioController`() = runTest {
+ val pos = Position(1.0, 2.0, 3)
+ useCase.setFixedPosition(123, pos)
+
+ coVerify { radioController.setFixedPosition(123, pos) }
+ }
+
+ @Test
+ fun `removeFixedPosition calls radioController with zero position`() = runTest {
+ useCase.removeFixedPosition(123)
+
+ coVerify { radioController.setFixedPosition(123, any()) }
+ }
+
+ @Test
+ fun `setRingtone calls radioController`() = runTest {
+ useCase.setRingtone(123, "ring")
+ coVerify { radioController.setRingtone(123, "ring") }
+ }
+
+ @Test
+ fun `getRingtone calls radioController and returns packetId`() = runTest {
+ val result = useCase.getRingtone(123)
+ coVerify { radioController.getRingtone(123, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `setCannedMessages calls radioController`() = runTest {
+ useCase.setCannedMessages(123, "msg")
+ coVerify { radioController.setCannedMessages(123, "msg") }
+ }
+
+ @Test
+ fun `getCannedMessages calls radioController and returns packetId`() = runTest {
+ val result = useCase.getCannedMessages(123)
+ coVerify { radioController.getCannedMessages(123, 42) }
+ assertEquals(42, result)
+ }
+
+ @Test
+ fun `getDeviceConnectionStatus calls radioController and returns packetId`() = runTest {
+ val result = useCase.getDeviceConnectionStatus(123)
+ coVerify { radioController.getDeviceConnectionStatus(123, 42) }
+ assertEquals(42, result)
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt
new file mode 100644
index 000000000..08e485c9a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.datastore.UiPreferencesDataSource
+
+class SetAppIntroCompletedUseCaseTest {
+
+ private lateinit var uiPreferencesDataSource: UiPreferencesDataSource
+ private lateinit var useCase: SetAppIntroCompletedUseCase
+
+ @Before
+ fun setUp() {
+ uiPreferencesDataSource = mockk(relaxed = true)
+ useCase = SetAppIntroCompletedUseCase(uiPreferencesDataSource)
+ }
+
+ @Test
+ fun `invoke calls setAppIntroCompleted on data source`() {
+ // Act
+ useCase(true)
+
+ // Assert
+ verify { uiPreferencesDataSource.setAppIntroCompleted(true) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt
new file mode 100644
index 000000000..1551ab32d
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 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.domain.usecase.settings
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.database.DatabaseConstants
+import org.meshtastic.core.database.DatabaseManager
+
+class SetDatabaseCacheLimitUseCaseTest {
+
+ private lateinit var databaseManager: DatabaseManager
+ private lateinit var useCase: SetDatabaseCacheLimitUseCase
+
+ @Before
+ fun setUp() {
+ databaseManager = mockk(relaxed = true)
+ useCase = SetDatabaseCacheLimitUseCase(databaseManager)
+ }
+
+ @Test
+ fun `invoke calls setCacheLimit with clamped value`() {
+ // Act & Assert
+ useCase(0)
+ verify { databaseManager.setCacheLimit(DatabaseConstants.MIN_CACHE_LIMIT) }
+
+ useCase(100)
+ verify { databaseManager.setCacheLimit(DatabaseConstants.MAX_CACHE_LIMIT) }
+
+ useCase(5)
+ verify { databaseManager.setCacheLimit(5) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt
new file mode 100644
index 000000000..748587b6a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt
@@ -0,0 +1,74 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+
+class SetMeshLogSettingsUseCaseTest {
+
+ private lateinit var meshLogRepository: MeshLogRepository
+ private lateinit var meshLogPrefs: MeshLogPrefs
+ private lateinit var useCase: SetMeshLogSettingsUseCase
+
+ @Before
+ fun setUp() {
+ meshLogRepository = mockk(relaxed = true)
+ meshLogPrefs = mockk(relaxed = true)
+ useCase = SetMeshLogSettingsUseCase(meshLogRepository, meshLogPrefs)
+ }
+
+ @Test
+ fun `setRetentionDays clamps and updates prefs and repository`() = runTest {
+ // Act
+ useCase.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS - 1)
+
+ // Assert
+ verify { meshLogPrefs.retentionDays = MeshLogPrefs.MIN_RETENTION_DAYS }
+ coVerify { meshLogRepository.deleteLogsOlderThan(MeshLogPrefs.MIN_RETENTION_DAYS) }
+ }
+
+ @Test
+ fun `setLoggingEnabled true triggers cleanup`() = runTest {
+ // Arrange
+ every { meshLogPrefs.retentionDays } returns 30
+
+ // Act
+ useCase.setLoggingEnabled(true)
+
+ // Assert
+ verify { meshLogPrefs.loggingEnabled = true }
+ coVerify { meshLogRepository.deleteLogsOlderThan(30) }
+ }
+
+ @Test
+ fun `setLoggingEnabled false triggers deletion`() = runTest {
+ // Act
+ useCase.setLoggingEnabled(false)
+
+ // Assert
+ verify { meshLogPrefs.loggingEnabled = false }
+ coVerify { meshLogRepository.deleteAll() }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt
new file mode 100644
index 000000000..240b07876
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.prefs.ui.UiPrefs
+
+class SetProvideLocationUseCaseTest {
+
+ private lateinit var uiPrefs: UiPrefs
+ private lateinit var useCase: SetProvideLocationUseCase
+
+ @Before
+ fun setUp() {
+ uiPrefs = mockk(relaxed = true)
+ useCase = SetProvideLocationUseCase(uiPrefs)
+ }
+
+ @Test
+ fun `invoke calls setShouldProvideNodeLocation on uiPrefs`() {
+ // Act
+ useCase(1234, true)
+
+ // Assert
+ verify { uiPrefs.setShouldProvideNodeLocation(1234, true) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt
new file mode 100644
index 000000000..7d04ce7bc
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.datastore.UiPreferencesDataSource
+
+class SetThemeUseCaseTest {
+
+ private lateinit var uiPreferencesDataSource: UiPreferencesDataSource
+ private lateinit var useCase: SetThemeUseCase
+
+ @Before
+ fun setUp() {
+ uiPreferencesDataSource = mockk(relaxed = true)
+ useCase = SetThemeUseCase(uiPreferencesDataSource)
+ }
+
+ @Test
+ fun `invoke calls setTheme on data source`() {
+ // Act
+ useCase(1)
+
+ // Assert
+ verify { uiPreferencesDataSource.setTheme(1) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt
new file mode 100644
index 000000000..63fbf2b2a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
+
+class ToggleAnalyticsUseCaseTest {
+
+ private lateinit var analyticsPrefs: AnalyticsPrefs
+ private lateinit var useCase: ToggleAnalyticsUseCase
+
+ @Before
+ fun setUp() {
+ analyticsPrefs = mockk(relaxed = true)
+ useCase = ToggleAnalyticsUseCase(analyticsPrefs)
+ }
+
+ @Test
+ fun `invoke toggles analytics from false to true`() {
+ // Arrange
+ every { analyticsPrefs.analyticsAllowed } returns false
+
+ // Act
+ useCase()
+
+ // Assert
+ verify { analyticsPrefs.analyticsAllowed = true }
+ }
+
+ @Test
+ fun `invoke toggles analytics from true to false`() {
+ // Arrange
+ every { analyticsPrefs.analyticsAllowed } returns true
+
+ // Act
+ useCase()
+
+ // Assert
+ verify { analyticsPrefs.analyticsAllowed = false }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt
new file mode 100644
index 000000000..f8cf978af
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.domain.usecase.settings
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
+
+class ToggleHomoglyphEncodingUseCaseTest {
+
+ private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
+ private lateinit var useCase: ToggleHomoglyphEncodingUseCase
+
+ @Before
+ fun setUp() {
+ homoglyphEncodingPrefs = mockk(relaxed = true)
+ useCase = ToggleHomoglyphEncodingUseCase(homoglyphEncodingPrefs)
+ }
+
+ @Test
+ fun `invoke toggles homoglyph encoding from false to true`() {
+ // Arrange
+ every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
+
+ // Act
+ useCase()
+
+ // Assert
+ verify { homoglyphEncodingPrefs.homoglyphEncodingEnabled = true }
+ }
+
+ @Test
+ fun `invoke toggles homoglyph encoding from true to false`() {
+ // Arrange
+ every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
+
+ // Act
+ useCase()
+
+ // Assert
+ verify { homoglyphEncodingPrefs.homoglyphEncodingEnabled = false }
+ }
+}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
similarity index 94%
rename from core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
index 0e8beedae..0af5a0efd 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 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,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-package org.meshtastic.core.service
+package org.meshtastic.core.model
sealed class ConnectionState {
/** We are disconnected from the device, and we should be trying to reconnect. */
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
new file mode 100644
index 000000000..286f32ddb
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 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
+
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.proto.ClientNotification
+
+@Suppress("TooManyFunctions")
+interface RadioController {
+ val connectionState: StateFlow
+ val clientNotification: StateFlow
+
+ suspend fun sendMessage(packet: DataPacket)
+
+ fun clearClientNotification()
+
+ // Abstracted ServiceActions
+ suspend fun favoriteNode(nodeNum: Int)
+
+ suspend fun sendSharedContact(nodeNum: Int)
+
+ // Radio configuration
+ suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int)
+
+ suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int)
+
+ suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int)
+
+ suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int)
+
+ suspend fun setFixedPosition(destNum: Int, position: Position)
+
+ suspend fun setRingtone(destNum: Int, ringtone: String)
+
+ suspend fun setCannedMessages(destNum: Int, messages: String)
+
+ // Admin get operations
+ suspend fun getOwner(destNum: Int, packetId: Int)
+
+ suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
+
+ suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
+
+ suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
+
+ suspend fun getRingtone(destNum: Int, packetId: Int)
+
+ suspend fun getCannedMessages(destNum: Int, packetId: Int)
+
+ suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
+
+ // Admin operations
+ suspend fun reboot(destNum: Int, packetId: Int)
+
+ suspend fun shutdown(destNum: Int, packetId: Int)
+
+ suspend fun factoryReset(destNum: Int, packetId: Int)
+
+ suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
+
+ suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
+
+ // Batch editing
+ suspend fun beginEditSettings(destNum: Int)
+
+ suspend fun commitEditSettings(destNum: Int)
+
+ // Helpers
+ fun getPacketId(): Int
+
+ /** Starts providing the phone's location to the mesh. */
+ fun startProvideLocation()
+
+ /** Stops providing the phone's location to the mesh. */
+ fun stopProvideLocation()
+}
diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts
index a71e0ec3a..8245b887e 100644
--- a/core/service/build.gradle.kts
+++ b/core/service/build.gradle.kts
@@ -16,7 +16,10 @@
*/
import com.android.build.api.dsl.LibraryExtension
-plugins { alias(libs.plugins.meshtastic.android.library) }
+plugins {
+ alias(libs.plugins.meshtastic.android.library)
+ alias(libs.plugins.meshtastic.hilt)
+}
configure {
buildFeatures { aidl = true }
@@ -28,6 +31,7 @@ configure {
dependencies {
api(projects.core.api)
implementation(projects.core.common)
+ implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.prefs)
@@ -39,4 +43,5 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
+ testImplementation(libs.turbine)
}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
new file mode 100644
index 000000000..ae582faa3
--- /dev/null
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 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.service
+
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.proto.ClientNotification
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+@Suppress("TooManyFunctions")
+class AndroidRadioControllerImpl
+@Inject
+constructor(
+ private val serviceRepository: ServiceRepository,
+ private val nodeRepository: NodeRepository,
+) : RadioController {
+
+ override val connectionState: StateFlow
+ get() = serviceRepository.connectionState
+
+ override val clientNotification: StateFlow
+ get() = serviceRepository.clientNotification
+
+ override suspend fun sendMessage(packet: DataPacket) {
+ // Bridging to the existing flow via IMeshService
+ serviceRepository.meshService?.send(packet)
+ }
+
+ override fun clearClientNotification() {
+ serviceRepository.clearClientNotification()
+ }
+
+ override suspend fun favoriteNode(nodeNum: Int) {
+ val nodeDef = nodeRepository.getNode(nodeNum.toString())
+ serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef))
+ }
+
+ override suspend fun sendSharedContact(nodeNum: Int) {
+ val nodeDef = nodeRepository.getNode(nodeNum.toString())
+ val contact =
+ org.meshtastic.proto.SharedContact(
+ node_num = nodeDef.num,
+ user = nodeDef.user,
+ manually_verified = nodeDef.manuallyVerified,
+ )
+ serviceRepository.onServiceAction(ServiceAction.SendContact(contact))
+ }
+
+ override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {
+ serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode())
+ }
+
+ override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {
+ serviceRepository.meshService?.setRemoteConfig(packetId, destNum, config.encode())
+ }
+
+ override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {
+ serviceRepository.meshService?.setModuleConfig(packetId, destNum, config.encode())
+ }
+
+ override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {
+ serviceRepository.meshService?.setRemoteChannel(packetId, destNum, channel.encode())
+ }
+
+ override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {
+ serviceRepository.meshService?.setFixedPosition(destNum, position)
+ }
+
+ override suspend fun setRingtone(destNum: Int, ringtone: String) {
+ serviceRepository.meshService?.setRingtone(destNum, ringtone)
+ }
+
+ override suspend fun setCannedMessages(destNum: Int, messages: String) {
+ serviceRepository.meshService?.setCannedMessages(destNum, messages)
+ }
+
+ override suspend fun getOwner(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.getRemoteOwner(packetId, destNum)
+ }
+
+ override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {
+ serviceRepository.meshService?.getRemoteConfig(packetId, destNum, configType)
+ }
+
+ override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {
+ serviceRepository.meshService?.getModuleConfig(packetId, destNum, moduleConfigType)
+ }
+
+ override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {
+ serviceRepository.meshService?.getRemoteChannel(packetId, destNum, index)
+ }
+
+ override suspend fun getRingtone(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.getRingtone(packetId, destNum)
+ }
+
+ override suspend fun getCannedMessages(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.getCannedMessages(packetId, destNum)
+ }
+
+ override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.getDeviceConnectionStatus(packetId, destNum)
+ }
+
+ override suspend fun reboot(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.requestReboot(packetId, destNum)
+ }
+
+ override suspend fun shutdown(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.requestShutdown(packetId, destNum)
+ }
+
+ override suspend fun factoryReset(destNum: Int, packetId: Int) {
+ serviceRepository.meshService?.requestFactoryReset(packetId, destNum)
+ }
+
+ override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {
+ serviceRepository.meshService?.requestNodedbReset(packetId, destNum, preserveFavorites)
+ }
+
+ override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
+ serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
+ }
+
+ override suspend fun beginEditSettings(destNum: Int) {
+ serviceRepository.meshService?.beginEditSettings(destNum)
+ }
+
+ override suspend fun commitEditSettings(destNum: Int) {
+ serviceRepository.meshService?.commitEditSettings(destNum)
+ }
+
+ override fun getPacketId(): Int = serviceRepository.meshService?.getPacketId() ?: 0
+
+ override fun startProvideLocation() {
+ serviceRepository.meshService?.startProvideLocation()
+ }
+
+ override fun stopProvideLocation() {
+ serviceRepository.meshService?.stopProvideLocation()
+ }
+}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
index 77f2b49c0..858e1695b 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
import javax.inject.Inject
@@ -44,7 +45,7 @@ data class TracerouteResponse(
/** Repository class for managing the [IMeshService] instance and connection state */
@Suppress("TooManyFunctions")
@Singleton
-class ServiceRepository @Inject constructor() {
+open class ServiceRepository @Inject constructor() {
var meshService: IMeshService? = null
private set
@@ -54,7 +55,7 @@ class ServiceRepository @Inject constructor() {
// Connection state to our radio device
private val _connectionState: MutableStateFlow = MutableStateFlow(ConnectionState.Disconnected)
- val connectionState: StateFlow
+ open val connectionState: StateFlow
get() = _connectionState
fun setConnectionState(connectionState: ConnectionState) {
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt
new file mode 100644
index 000000000..0df2b76e5
--- /dev/null
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 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.service.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.service.AndroidRadioControllerImpl
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class ServiceModule {
+
+ @Binds abstract fun bindRadioController(impl: AndroidRadioControllerImpl): RadioController
+}
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index 81d60db69..84a5e9538 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -44,6 +44,7 @@ import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.datastore.BootloaderWarningDataSource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
@@ -71,7 +72,6 @@ import org.meshtastic.core.resources.firmware_update_unknown_hardware
import org.meshtastic.core.resources.firmware_update_updating
import org.meshtastic.core.resources.firmware_update_validating
import org.meshtastic.core.resources.unknown
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import java.io.File
import javax.inject.Inject
diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
index 5147bef41..7a4215220 100644
--- a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
+++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
@@ -36,7 +36,7 @@ import com.google.accompanist.permissions.rememberPermissionState
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
-fun AppIntroductionScreen(onDone: () -> Unit, @Suppress("unused") viewModel: IntroViewModel = hiltViewModel()) {
+fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel = hiltViewModel()) {
val notificationPermissionState: PermissionState? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
diff --git a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
index 571f3ac0d..10972edb3 100644
--- a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
+++ b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
@@ -44,9 +44,9 @@ 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.datastore.UiPreferencesDataSource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.MapPrefs
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.robolectric.RobolectricTestRunner
diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts
index 36cbbe824..97b81c776 100644
--- a/feature/messaging/build.gradle.kts
+++ b/feature/messaging/build.gradle.kts
@@ -26,8 +26,10 @@ configure { namespace = "org.meshtastic.feature.messaging" }
dependencies {
implementation(projects.core.analytics)
+ implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
+ implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.prefs)
@@ -50,6 +52,9 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.kermit)
+ implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.androidx.hilt.work)
+ ksp(libs.androidx.hilt.compiler)
debugImplementation(libs.androidx.compose.ui.test.manifest)
@@ -59,4 +64,8 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.turbine)
+ testImplementation(libs.androidx.work.testing)
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.robolectric)
}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
index 2c2835c75..91bda8f2e 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -100,6 +100,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index 774faac4a..174b48588 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -39,6 +39,7 @@ import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.usecase.SendMessageUseCase
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
@@ -47,7 +48,6 @@ import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
-import org.meshtastic.feature.messaging.domain.usecase.SendMessageUseCase
import org.meshtastic.proto.ChannelSet
import javax.inject.Inject
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt
new file mode 100644
index 000000000..616765d1d
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 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.feature.messaging.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.domain.MessageQueue
+import org.meshtastic.feature.messaging.domain.worker.WorkManagerMessageQueue
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class MessagingModule {
+
+ @Binds abstract fun bindMessageQueue(impl: WorkManagerMessageQueue): MessageQueue
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt
new file mode 100644
index 000000000..49d11fa10
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 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.feature.messaging.domain.worker
+
+import android.content.Context
+import androidx.hilt.work.HiltWorker
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.RadioController
+
+@HiltWorker
+class SendMessageWorker
+@AssistedInject
+constructor(
+ @Assisted context: Context,
+ @Assisted params: WorkerParameters,
+ private val packetRepository: PacketRepository,
+ private val radioController: RadioController,
+) : CoroutineWorker(context, params) {
+
+ @Suppress("TooGenericExceptionCaught", "SwallowedException", "ReturnCount")
+ override suspend fun doWork(): Result {
+ val packetId = inputData.getInt(KEY_PACKET_ID, 0)
+ if (packetId == 0) return Result.failure()
+
+ // Verify we are connected before attempting to send to avoid unnecessary Exception bubbling
+ if (radioController.connectionState.value != ConnectionState.Connected) {
+ return Result.retry()
+ }
+
+ val packetEntity =
+ packetRepository.getPacketByPacketId(packetId)
+ ?: return Result.failure() // Packet no longer exists in DB? Do not retry.
+
+ val packetData = packetEntity.packet.data
+
+ return try {
+ radioController.sendMessage(packetData)
+ packetRepository.updateMessageStatus(packetData, MessageStatus.ENROUTE)
+ Result.success()
+ } catch (e: Exception) {
+ packetRepository.updateMessageStatus(packetData, MessageStatus.ERROR)
+ Result.retry()
+ }
+ }
+
+ companion object {
+ const val KEY_PACKET_ID = "packet_id"
+ const val WORK_NAME_PREFIX = "send_message_"
+ }
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt
new file mode 100644
index 000000000..a7b829be0
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 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.feature.messaging.domain.worker
+
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import org.meshtastic.core.domain.MessageQueue
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Android implementation of [MessageQueue] that uses [WorkManager] for reliable background transmission. */
+@Singleton
+class WorkManagerMessageQueue @Inject constructor(private val workManager: WorkManager) : MessageQueue {
+
+ override suspend fun enqueue(packetId: Int) {
+ val workRequest =
+ OneTimeWorkRequestBuilder()
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .build()
+
+ workManager.enqueueUniqueWork(
+ "${SendMessageWorker.WORK_NAME_PREFIX}$packetId",
+ ExistingWorkPolicy.REPLACE,
+ workRequest,
+ )
+ }
+}
diff --git a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt
index f521c5e07..b5b634d6a 100644
--- a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt
+++ b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt
@@ -19,6 +19,7 @@ package org.meshtastic.feature.messaging
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
+import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
class HomoglyphCharacterTransformTest {
diff --git a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt
new file mode 100644
index 000000000..48abe99de
--- /dev/null
+++ b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) 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.feature.messaging.domain.worker
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.work.ListenableWorker
+import androidx.work.WorkerParameters
+import androidx.work.testing.TestListenableWorkerBuilder
+import androidx.work.workDataOf
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.data.repository.PacketRepository
+import org.meshtastic.core.database.entity.Packet
+import org.meshtastic.core.database.entity.PacketEntity
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.RadioController
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SendMessageWorkerTest {
+
+ private lateinit var context: Context
+ private lateinit var packetRepository: PacketRepository
+ private lateinit var radioController: RadioController
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ packetRepository = mockk(relaxed = true)
+ radioController = mockk(relaxed = true)
+ every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
+ }
+
+ @Test
+ fun `doWork returns success when packet is sent successfully`() = runTest {
+ // Arrange
+ val packetId = 12345
+ val dataPacket = DataPacket("dest", 0, "Hello")
+ val packet = mockk(relaxed = true)
+ val packetEntity = PacketEntity(packet = packet)
+ every { packet.data } returns dataPacket
+ coEvery { packetRepository.getPacketByPacketId(packetId) } returns packetEntity
+ every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
+ coEvery { radioController.sendMessage(any()) } just Runs
+ coEvery { packetRepository.updateMessageStatus(any(), any()) } just Runs
+
+ val worker =
+ TestListenableWorkerBuilder(context)
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .setWorkerFactory(
+ object : androidx.work.WorkerFactory() {
+ override fun createWorker(
+ appContext: Context,
+ workerClassName: String,
+ workerParameters: WorkerParameters,
+ ): ListenableWorker? =
+ SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
+ },
+ )
+ .build()
+
+ // Act
+ val result = worker.doWork()
+
+ // Assert
+ assertEquals(ListenableWorker.Result.success(), result)
+ coVerify { radioController.sendMessage(dataPacket) }
+ coVerify { packetRepository.updateMessageStatus(dataPacket, MessageStatus.ENROUTE) }
+ }
+
+ @Test
+ fun `doWork returns retry when radio is disconnected`() = runTest {
+ // Arrange
+ val packetId = 12345
+ val dataPacket = DataPacket("dest", 0, "Hello")
+ val packet = mockk(relaxed = true)
+ val packetEntity = PacketEntity(packet = packet)
+ every { packet.data } returns dataPacket
+ coEvery { packetRepository.getPacketByPacketId(packetId) } returns packetEntity
+ every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
+
+ val worker =
+ TestListenableWorkerBuilder(context)
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .setWorkerFactory(
+ object : androidx.work.WorkerFactory() {
+ override fun createWorker(
+ appContext: Context,
+ workerClassName: String,
+ workerParameters: WorkerParameters,
+ ): ListenableWorker? =
+ SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
+ },
+ )
+ .build()
+
+ // Act
+ val result = worker.doWork()
+
+ // Assert
+ assertEquals(ListenableWorker.Result.retry(), result)
+ coVerify(exactly = 0) { radioController.sendMessage(any()) }
+ }
+
+ @Test
+ fun `doWork returns failure when packet is missing`() = runTest {
+ // Arrange
+ val packetId = 999
+ coEvery { packetRepository.getPacketByPacketId(packetId) } returns null
+
+ val worker =
+ TestListenableWorkerBuilder(context)
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .setWorkerFactory(
+ object : androidx.work.WorkerFactory() {
+ override fun createWorker(
+ appContext: Context,
+ workerClassName: String,
+ workerParameters: WorkerParameters,
+ ): ListenableWorker? =
+ SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
+ },
+ )
+ .build()
+
+ // Act
+ val result = worker.doWork()
+
+ // Assert
+ assertEquals(ListenableWorker.Result.failure(), result)
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index 755c68175..f8b895552 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
@@ -63,7 +64,6 @@ import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.voltage
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.AirQualityInfo
import org.meshtastic.core.ui.component.ChannelInfo
import org.meshtastic.core.ui.component.DistanceInfo
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
index 919515426..5546b3cbe 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
@@ -37,6 +37,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
@@ -46,7 +47,6 @@ import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.unmessageable
import org.meshtastic.core.resources.unmonitored_or_infrastructure
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.icon.CloudDone
import org.meshtastic.core.ui.icon.CloudOffTwoTone
import org.meshtastic.core.ui.icon.CloudSync
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 6b95f55fa..f2a823296 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -68,6 +68,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_favorite
import org.meshtastic.core.resources.channel_invalid
@@ -79,7 +80,6 @@ import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_favorite
import org.meshtastic.core.resources.remove_ignored
import org.meshtastic.core.resources.unmute
-import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index 9a7de65a8..5c02a427e 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -33,6 +33,7 @@ dependencies {
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)
+ implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.nfc)
@@ -57,7 +58,10 @@ dependencies {
implementation(libs.nordic.common.permissions.ble)
testImplementation(libs.junit)
+ testImplementation(libs.mockk)
testImplementation(libs.robolectric)
+ testImplementation(libs.turbine)
+ testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit)
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
index 1d5c16f4e..d5fbcc31f 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
@@ -47,8 +47,8 @@ import org.meshtastic.core.resources.preserve_favorites
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.radio.AdminRoute
-import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigState
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.ResponseState
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
index 77dc42419..61d551d8e 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
@@ -35,8 +35,8 @@ import org.meshtastic.core.resources.device_configuration
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
-import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
index 630d19c0b..788292573 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
@@ -36,8 +36,8 @@ import org.meshtastic.core.resources.module_settings
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ModuleRoute
-import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index bd5ebc655..d24a6c1cd 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -16,101 +16,54 @@
*/
package org.meshtastic.feature.settings
-import android.Manifest
import android.app.Activity
import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import android.provider.Settings
-import android.provider.Settings.ACTION_APP_LOCALE_SETTINGS
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.annotation.VisibleForTesting
-import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
-import androidx.compose.material.icons.filled.Abc
-import androidx.compose.material.icons.filled.BugReport
-import androidx.compose.material.icons.rounded.AppSettingsAlt
-import androidx.compose.material.icons.rounded.FormatPaint
-import androidx.compose.material.icons.rounded.Info
-import androidx.compose.material.icons.rounded.Language
-import androidx.compose.material.icons.rounded.LocationOn
-import androidx.compose.material.icons.rounded.Memory
-import androidx.compose.material.icons.rounded.Output
-import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
-import androidx.core.net.toUri
-import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.rememberMultiplePermissionsState
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
-import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.acknowledgements
-import org.meshtastic.core.resources.analytics_okay
-import org.meshtastic.core.resources.app_settings
-import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.choose_theme
-import org.meshtastic.core.resources.device_db_cache_limit
-import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.dynamic
import org.meshtastic.core.resources.export_configuration
-import org.meshtastic.core.resources.export_data_csv
import org.meshtastic.core.resources.import_configuration
-import org.meshtastic.core.resources.intro_show
-import org.meshtastic.core.resources.location_disabled
-import org.meshtastic.core.resources.modules_already_unlocked
-import org.meshtastic.core.resources.modules_unlocked
import org.meshtastic.core.resources.preferences_language
-import org.meshtastic.core.resources.provide_location_to_mesh
import org.meshtastic.core.resources.remotely_administrating
-import org.meshtastic.core.resources.save_rangetest
-import org.meshtastic.core.resources.system_settings
-import org.meshtastic.core.resources.theme
import org.meshtastic.core.resources.theme_dark
import org.meshtastic.core.resources.theme_light
import org.meshtastic.core.resources.theme_system
-import org.meshtastic.core.resources.use_homoglyph_characters_encoding
-import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
-import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
-import org.meshtastic.core.ui.util.showToast
+import org.meshtastic.feature.settings.component.AppInfoSection
+import org.meshtastic.feature.settings.component.AppearanceSection
+import org.meshtastic.feature.settings.component.PersistenceSection
+import org.meshtastic.feature.settings.component.PrivacySection
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
-import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigItemList
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
@@ -119,7 +72,6 @@ import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
import org.meshtastic.proto.DeviceProfile
import java.text.SimpleDateFormat
import java.util.Locale
-import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@@ -259,226 +211,37 @@ fun SettingsScreen(
onNavigate = onNavigate,
)
- val context = LocalContext.current
+ PrivacySection(
+ analyticsAvailable = state.analyticsAvailable,
+ analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false).value,
+ onToggleAnalytics = { viewModel.toggleAnalyticsAllowed() },
+ provideLocation = settingsViewModel.provideLocation.collectAsStateWithLifecycle().value,
+ onToggleLocation = { settingsViewModel.setProvideLocation(it) },
+ homoglyphEnabled = viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false).value,
+ onToggleHomoglyph = { viewModel.toggleHomoglyphCharactersEncodingEnabled() },
+ startProvideLocation = { settingsViewModel.startProvidingLocation() },
+ stopProvideLocation = { settingsViewModel.stopProvidingLocation() },
+ )
- ExpressiveSection(title = stringResource(Res.string.app_settings)) {
- if (state.analyticsAvailable) {
- val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
- SwitchListItem(
- text = stringResource(Res.string.analytics_okay),
- checked = allowed,
- leadingIcon = Icons.Default.BugReport,
- onClick = { viewModel.toggleAnalyticsAllowed() },
- )
- }
+ AppearanceSection(
+ onShowLanguagePicker = { showLanguagePickerDialog = true },
+ onShowThemePicker = { showThemePickerDialog = true },
+ )
- val locationPermissionsState =
- rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
- val isGpsDisabled = context.gpsDisabled()
- val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle()
+ PersistenceSection(
+ cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
+ onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
+ nodeShortName = ourNode?.user?.short_name ?: "",
+ onExportData = { settingsViewModel.saveDataCsv(it) },
+ )
- LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
- if (provideLocation) {
- if (locationPermissionsState.allPermissionsGranted) {
- if (!isGpsDisabled) {
- settingsViewModel.meshService?.startProvideLocation()
- } else {
- context.showToast(Res.string.location_disabled)
- }
- } else {
- // Request permissions if not granted and user wants to provide location
- locationPermissionsState.launchMultiplePermissionRequest()
- }
- } else {
- settingsViewModel.meshService?.stopProvideLocation()
- }
- }
-
- SwitchListItem(
- text = stringResource(Res.string.provide_location_to_mesh),
- leadingIcon = Icons.Rounded.LocationOn,
- enabled = !isGpsDisabled,
- checked = provideLocation,
- onClick = { settingsViewModel.setProvideLocation(!provideLocation) },
- )
-
- val homoglyphEncodingEnabled by
- viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false)
-
- HomoglyphSetting(
- homoglyphEncodingEnabled = homoglyphEncodingEnabled,
- onToggle = { viewModel.toggleHomoglyphCharactersEncodingEnabled() },
- )
-
- val settingsLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
-
- // On Android 12 and below, system app settings for language are not available. Use the in-app language
- // picker for these devices.
- val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
- ListItem(
- text = stringResource(Res.string.preferences_language),
- leadingIcon = Icons.Rounded.Language,
- trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- ) {
- if (useInAppLangPicker) {
- showLanguagePickerDialog = true
- } else {
- val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
- if (intent.resolveActivity(context.packageManager) != null) {
- settingsLauncher.launch(intent)
- } else {
- // Fall back to the in-app picker
- showLanguagePickerDialog = true
- }
- }
- }
-
- ListItem(
- text = stringResource(Res.string.theme),
- leadingIcon = Icons.Rounded.FormatPaint,
- trailingIcon = null,
- ) {
- showThemePickerDialog = true
- }
-
- // Node DB cache limit (App setting)
- val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value
- val cacheItems = remember {
- (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
- it.toLong() to it.toString()
- }
- }
- DropDownPreference(
- title = stringResource(Res.string.device_db_cache_limit),
- enabled = true,
- items = cacheItems,
- selectedItem = cacheLimit.toLong(),
- onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
- summary = stringResource(Res.string.device_db_cache_limit_summary),
- )
-
- val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
- val nodeName = ourNode?.user?.short_name ?: ""
-
- val exportRangeTestLauncher =
- rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
- }
- }
- ListItem(
- text = stringResource(Res.string.save_rangetest),
- leadingIcon = Icons.Rounded.Output,
- trailingIcon = null,
- ) {
- val intent =
- Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "application/csv"
- putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeName}_$timestamp.csv")
- }
- exportRangeTestLauncher.launch(intent)
- }
-
- val exportDataLauncher =
- rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
- }
- }
- ListItem(
- text = stringResource(Res.string.export_data_csv),
- leadingIcon = Icons.Rounded.Output,
- trailingIcon = null,
- ) {
- val intent =
- Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "application/csv"
- putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeName}_$timestamp.csv")
- }
- exportDataLauncher.launch(intent)
- }
-
- ListItem(
- text = stringResource(Res.string.intro_show),
- leadingIcon = Icons.Rounded.WavingHand,
- trailingIcon = null,
- ) {
- settingsViewModel.showAppIntro()
- }
-
- ListItem(
- text = stringResource(Res.string.system_settings),
- leadingIcon = Icons.Rounded.AppSettingsAlt,
- trailingIcon = null,
- ) {
- val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
- intent.data = Uri.fromParts("package", context.packageName, null)
- settingsLauncher.launch(intent)
- }
-
- ListItem(
- text = stringResource(Res.string.acknowledgements),
- leadingIcon = Icons.Rounded.Info,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- ) {
- onNavigate(SettingsRoutes.About)
- }
-
- AppVersionButton(
- excludedModulesUnlocked = excludedModulesUnlocked,
- appVersionName = settingsViewModel.appVersionName,
- ) {
- settingsViewModel.unlockExcludedModules()
- }
- }
- }
- }
-}
-
-private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
-private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
-private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
-
-/** A button to display the app version. Clicking it 5 times will unlock the excluded modules. */
-@Composable
-private fun AppVersionButton(
- excludedModulesUnlocked: Boolean,
- appVersionName: String,
- onUnlockExcludedModules: () -> Unit,
-) {
- val scope = rememberCoroutineScope()
- val context = LocalContext.current
- var clickCount by remember { mutableIntStateOf(0) }
-
- LaunchedEffect(clickCount) {
- if (clickCount in 1.. {
- clickCount = 0
- scope.launch { context.showToast(Res.string.modules_already_unlocked) }
- }
-
- clickCount == UNLOCK_CLICK_COUNT -> {
- clickCount = 0
- onUnlockExcludedModules()
- scope.launch { context.showToast(Res.string.modules_unlocked) }
- }
+ AppInfoSection(
+ appVersionName = settingsViewModel.appVersionName,
+ excludedModulesUnlocked = excludedModulesUnlocked,
+ onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() },
+ onShowAppIntro = { settingsViewModel.showAppIntro() },
+ onNavigateToAbout = { onNavigate(SettingsRoutes.About) },
+ )
}
}
}
@@ -525,18 +288,3 @@ private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit
},
)
}
-
-@VisibleForTesting
-@Composable
-fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) {
- val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
- val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn")
- if (currentLocale?.language in supportedLanguages) {
- SwitchListItem(
- text = stringResource(Res.string.use_homoglyph_characters_encoding),
- checked = homoglyphEncodingEnabled,
- leadingIcon = Icons.Default.Abc,
- onClick = onToggle,
- )
- }
-}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
index 9ed773068..a75296c13 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
@@ -16,8 +16,6 @@
*/
package org.meshtastic.feature.settings
-import android.app.Application
-import android.icu.text.SimpleDateFormat
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -27,64 +25,57 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.BuildConfigProvider
-import org.meshtastic.core.data.repository.DeviceHardwareRepository
-import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.datastore.UiPreferencesDataSource
-import org.meshtastic.core.model.Capabilities
-import org.meshtastic.core.model.Position
-import org.meshtastic.core.model.util.positionToMeter
+import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
+import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
+import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
+import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
+import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
+import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
+import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
+import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
-import org.meshtastic.core.prefs.radio.RadioPrefs
-import org.meshtastic.core.prefs.radio.isBle
-import org.meshtastic.core.prefs.radio.isSerial
-import org.meshtastic.core.prefs.radio.isTcp
import org.meshtastic.core.prefs.ui.UiPrefs
-import org.meshtastic.core.service.IMeshService
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
-import org.meshtastic.proto.PortNum
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
-import java.util.Locale
import javax.inject.Inject
-import kotlin.math.roundToInt
-import org.meshtastic.proto.Position as ProtoPosition
-@Suppress("LongParameterList")
+@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class SettingsViewModel
@Inject
constructor(
- private val app: Application,
+ private val app: android.app.Application,
radioConfigRepository: RadioConfigRepository,
- private val serviceRepository: ServiceRepository,
+ private val radioController: RadioController,
private val nodeRepository: NodeRepository,
- private val meshLogRepository: MeshLogRepository,
private val uiPrefs: UiPrefs,
- private val uiPreferencesDataSource: UiPreferencesDataSource,
private val buildConfigProvider: BuildConfigProvider,
private val databaseManager: DatabaseManager,
- private val deviceHardwareRepository: DeviceHardwareRepository,
- private val radioPrefs: RadioPrefs,
private val meshLogPrefs: MeshLogPrefs,
+ private val setThemeUseCase: SetThemeUseCase,
+ private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
+ private val setProvideLocationUseCase: SetProvideLocationUseCase,
+ private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase,
+ private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase,
+ private val meshLocationUseCase: MeshLocationUseCase,
+ private val exportDataUseCase: ExportDataUseCase,
+ private val isOtaCapableUseCase: IsOtaCapableUseCase,
) : ViewModel() {
val myNodeInfo: StateFlow = nodeRepository.myNodeInfo
@@ -94,14 +85,11 @@ constructor(
val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo
val isConnected =
- serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
+ radioController.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
val localConfig: StateFlow =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
- val meshService: IMeshService?
- get() = serviceRepository.meshService
-
val provideLocation: StateFlow =
myNodeInfo
.flatMapLatest { myNodeEntity ->
@@ -114,41 +102,27 @@ constructor(
}
.stateInWhileSubscribed(initialValue = false)
+ fun startProvidingLocation() {
+ meshLocationUseCase.startProvidingLocation()
+ }
+
+ fun stopProvidingLocation() {
+ meshLocationUseCase.stopProvidingLocation()
+ }
+
private val _excludedModulesUnlocked = MutableStateFlow(false)
val excludedModulesUnlocked: StateFlow = _excludedModulesUnlocked.asStateFlow()
val appVersionName
get() = buildConfigProvider.versionName
- val isOtaCapable: StateFlow =
- combine(ourNodeInfo, serviceRepository.connectionState) { node, connectionState -> Pair(node, connectionState) }
- .flatMapLatest { (node, connectionState) ->
- if (node == null || !connectionState.isConnected()) {
- flowOf(false)
- } else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
- val hwModel = node.user.hw_model.value
- val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
- // Support both Nordic DFU (requiresDfu) and ESP32 Unified OTA (supportsUnifiedOta)
- val capabilities = Capabilities(node.metadata?.firmware_version)
-
- // ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial.
- // TODO: Re-enable when supportsUnifiedOta is added to DeviceHardware
- val isEsp32OtaSupported = false
- // hw?.supportsUnifiedOta == true && capabilities.supportsEsp32Ota && !radioPrefs.isSerial()
-
- flow { emit(hw?.requiresDfu == true || isEsp32OtaSupported) }
- } else {
- flowOf(false)
- }
- }
- .stateInWhileSubscribed(initialValue = false)
+ val isOtaCapable: StateFlow = isOtaCapableUseCase().stateInWhileSubscribed(initialValue = false)
// Device DB cache limit (bounded by DatabaseConstants)
val dbCacheLimit: StateFlow = databaseManager.cacheLimit
fun setDbCacheLimit(limit: Int) {
- val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
- databaseManager.setCacheLimit(clamped)
+ setDatabaseCacheLimitUseCase(limit)
}
// MeshLog retention period (bounded by MeshLogPrefsImpl constants)
@@ -159,32 +133,25 @@ constructor(
val meshLogLoggingEnabled: StateFlow = _meshLogLoggingEnabled.asStateFlow()
fun setMeshLogRetentionDays(days: Int) {
- val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
- meshLogPrefs.retentionDays = clamped
- _meshLogRetentionDays.value = clamped
- viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) }
+ viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) }
+ _meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
}
fun setMeshLogLoggingEnabled(enabled: Boolean) {
- meshLogPrefs.loggingEnabled = enabled
+ viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) }
_meshLogLoggingEnabled.value = enabled
- if (!enabled) {
- viewModelScope.launch { meshLogRepository.deleteAll() }
- } else {
- viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays) }
- }
}
fun setProvideLocation(value: Boolean) {
- myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
+ myNodeNum?.let { setProvideLocationUseCase(it, value) }
}
fun setTheme(theme: Int) {
- uiPreferencesDataSource.setTheme(theme)
+ setThemeUseCase(theme)
}
fun showAppIntro() {
- uiPreferencesDataSource.setAppIntroCompleted(false)
+ setAppIntroCompletedUseCase(false)
}
fun unlockExcludedModules() {
@@ -204,112 +171,8 @@ constructor(
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod")
fun saveDataCsv(uri: Uri, filterPortnum: Int? = null) {
viewModelScope.launch(Dispatchers.Main) {
- // Extract distances to this device from position messages and put (node,SNR,distance)
- // in the file_uri
val myNodeNum = myNodeNum ?: return@launch
-
- // Capture the current node value while we're still on main thread
- val nodes = nodeRepository.nodeDBbyNum.value
-
- // Converts a ProtoPosition (nullable) to a Position, but only if it's valid, otherwise returns null.
- // The returned Position is guaranteed to be non-null and valid, or null if the input was null or invalid.
- val positionToPos: (ProtoPosition?) -> Position? = { meshPosition ->
- meshPosition?.let { Position(it) }?.takeIf { it.isValid() }
- }
-
- writeToUri(uri) { writer ->
- val nodePositions = mutableMapOf()
-
- @Suppress("MaxLineLength")
- writer.appendLine(
- "\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance(m)\",\"hop limit\",\"payload\"",
- )
-
- // Packets are ordered by time, we keep most recent position of
- // our device in localNodePosition.
- val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
- meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
- // If we get a NodeInfo packet, use it to update our position data (if valid)
- packet.nodeInfo?.let { nodeInfo ->
- positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
- }
-
- packet.meshPacket?.let { proto ->
- // If the packet contains position data then use it to update, if valid
- packet.position?.let { position ->
- positionToPos.invoke(position)?.let {
- nodePositions[
- proto.from.takeIf { it != 0 } ?: myNodeNum,
- ] = position
- }
- }
-
- // packets must have rxSNR, and optionally match the filter given as a param.
- if (
- (filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
- (proto.rx_snr ?: 0f) != 0.0f
- ) {
- val rxDateTime = dateFormat.format(packet.received_date)
- val rxFrom = proto.from.toUInt()
- val senderName = nodes[proto.from]?.user?.long_name ?: ""
-
- // sender lat & long
- val senderPosition = nodePositions[proto.from]
- val senderPos = positionToPos.invoke(senderPosition)
- val senderLat = senderPos?.latitude ?: ""
- val senderLong = senderPos?.longitude ?: ""
-
- // rx lat, long, and elevation
- val rxPosition = nodePositions[myNodeNum]
- val rxPos = positionToPos.invoke(rxPosition)
- val rxLat = rxPos?.latitude ?: ""
- val rxLong = rxPos?.longitude ?: ""
- val rxAlt = rxPos?.altitude ?: ""
- val rxSnr = proto.rx_snr
-
- // Calculate the distance if both positions are valid
-
- val dist =
- if (senderPos == null || rxPos == null) {
- ""
- } else {
- positionToMeter(
- Position(rxPosition!!), // Use rxPosition but only if rxPos was
- // valid
- Position(senderPosition!!), // Use senderPosition but only if
- // senderPos was valid
- )
- .roundToInt()
- .toString()
- }
-
- val hopLimit = proto.hop_limit ?: 0
-
- val decoded = proto.decoded
- val encrypted = proto.encrypted
- val payload =
- when {
- (decoded?.portnum?.value ?: 0) !in
- setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.RANGE_TEST_APP.value) ->
- "<${decoded?.portnum}>"
-
- decoded != null -> decoded.payload.utf8().replace("\"", "\"\"")
-
- encrypted != null -> "${encrypted.size} encrypted bytes"
- else -> ""
- }
-
- // date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
- // elevation,rx
- // snr,distance,hop limit,payload
- @Suppress("MaxLineLength")
- writer.appendLine(
- "$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
- )
- }
- }
- }
- }
+ writeToUri(uri) { writer -> exportDataUseCase(writer, myNodeNum, filterPortnum) }
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt
new file mode 100644
index 000000000..cb6ef918b
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt
@@ -0,0 +1,159 @@
+/*
+ * 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.feature.settings.component
+
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.rounded.AppSettingsAlt
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Memory
+import androidx.compose.material.icons.rounded.WavingHand
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.acknowledgements
+import org.meshtastic.core.resources.app_version
+import org.meshtastic.core.resources.info
+import org.meshtastic.core.resources.intro_show
+import org.meshtastic.core.resources.modules_already_unlocked
+import org.meshtastic.core.resources.modules_unlocked
+import org.meshtastic.core.resources.system_settings
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.util.showToast
+import kotlin.time.Duration.Companion.seconds
+
+/** Section displaying application information and related actions. */
+@Composable
+fun AppInfoSection(
+ appVersionName: String,
+ excludedModulesUnlocked: Boolean,
+ onUnlockExcludedModules: () -> Unit,
+ onShowAppIntro: () -> Unit,
+ onNavigateToAbout: () -> Unit,
+) {
+ val context = LocalContext.current
+ val settingsLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
+
+ ExpressiveSection(title = stringResource(Res.string.info)) {
+ ListItem(
+ text = stringResource(Res.string.intro_show),
+ leadingIcon = Icons.Rounded.WavingHand,
+ trailingIcon = null,
+ ) {
+ onShowAppIntro()
+ }
+
+ ListItem(
+ text = stringResource(Res.string.system_settings),
+ leadingIcon = Icons.Rounded.AppSettingsAlt,
+ trailingIcon = null,
+ ) {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ intent.data = Uri.fromParts("package", context.packageName, null)
+ settingsLauncher.launch(intent)
+ }
+
+ ListItem(
+ text = stringResource(Res.string.acknowledgements),
+ leadingIcon = Icons.Rounded.Info,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ ) {
+ onNavigateToAbout()
+ }
+
+ AppVersionButton(
+ excludedModulesUnlocked = excludedModulesUnlocked,
+ appVersionName = appVersionName,
+ onUnlockExcludedModules = onUnlockExcludedModules,
+ )
+ }
+}
+
+private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
+private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
+private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
+
+@Composable
+private fun AppVersionButton(
+ excludedModulesUnlocked: Boolean,
+ appVersionName: String,
+ onUnlockExcludedModules: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ var clickCount by remember { mutableIntStateOf(0) }
+
+ LaunchedEffect(clickCount) {
+ if (clickCount in 1.. {
+ clickCount = 0
+ scope.launch { context.showToast(Res.string.modules_already_unlocked) }
+ }
+
+ clickCount == UNLOCK_CLICK_COUNT -> {
+ clickCount = 0
+ onUnlockExcludedModules()
+ scope.launch { context.showToast(Res.string.modules_unlocked) }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AppInfoSectionPreview() {
+ AppTheme {
+ AppInfoSection(
+ appVersionName = "2.5.0",
+ excludedModulesUnlocked = false,
+ onUnlockExcludedModules = {},
+ onShowAppIntro = {},
+ onNavigateToAbout = {},
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
new file mode 100644
index 000000000..48807d8fa
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.feature.settings.component
+
+import android.content.Intent
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.rounded.FormatPaint
+import androidx.compose.material.icons.rounded.Language
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.core.net.toUri
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.app_settings
+import org.meshtastic.core.resources.preferences_language
+import org.meshtastic.core.resources.theme
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.theme.AppTheme
+
+/** Section for app appearance settings like language and theme. */
+@Composable
+fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) {
+ val context = LocalContext.current
+ val settingsLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
+
+ // On Android 12 and below, system app settings for language are not available. Use the in-app language
+ // picker for these devices.
+ val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
+
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
+ ListItem(
+ text = stringResource(Res.string.preferences_language),
+ leadingIcon = Icons.Rounded.Language,
+ trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ ) {
+ if (useInAppLangPicker) {
+ onShowLanguagePicker()
+ } else {
+ val intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
+ if (intent.resolveActivity(context.packageManager) != null) {
+ settingsLauncher.launch(intent)
+ } else {
+ // Fall back to the in-app picker
+ onShowLanguagePicker()
+ }
+ }
+ }
+
+ ListItem(
+ text = stringResource(Res.string.theme),
+ leadingIcon = Icons.Rounded.FormatPaint,
+ trailingIcon = null,
+ ) {
+ onShowThemePicker()
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AppearanceSectionPreview() {
+ AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt
new file mode 100644
index 000000000..49dbe2252
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.feature.settings.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+/** A styled section container for settings screens. */
+@Composable
+fun ExpressiveSection(
+ title: String,
+ modifier: Modifier = Modifier,
+ titleColor: Color = MaterialTheme.colorScheme.primary,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = titleColor,
+ )
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
+ content = content,
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt
new file mode 100644
index 000000000..161367ee2
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.feature.settings.component
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Abc
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.core.os.ConfigurationCompat
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.use_homoglyph_characters_encoding
+import org.meshtastic.core.ui.component.SwitchListItem
+
+@Composable
+fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) {
+ val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
+ val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn")
+ if (currentLocale?.language in supportedLanguages) {
+ SwitchListItem(
+ text = stringResource(Res.string.use_homoglyph_characters_encoding),
+ checked = homoglyphEncodingEnabled,
+ leadingIcon = Icons.Default.Abc,
+ onClick = onToggle,
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
new file mode 100644
index 000000000..c22235bd2
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
@@ -0,0 +1,116 @@
+/*
+ * 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.feature.settings.component
+
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity.RESULT_OK
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Output
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.tooling.preview.Preview
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.toDate
+import org.meshtastic.core.common.util.toInstant
+import org.meshtastic.core.database.DatabaseConstants
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.app_settings
+import org.meshtastic.core.resources.device_db_cache_limit
+import org.meshtastic.core.resources.device_db_cache_limit_summary
+import org.meshtastic.core.resources.export_data_csv
+import org.meshtastic.core.resources.save_rangetest
+import org.meshtastic.core.ui.component.DropDownPreference
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.theme.AppTheme
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+/** Section for settings related to data persistence and exports. */
+@Composable
+fun PersistenceSection(
+ cacheLimit: Int,
+ onSetCacheLimit: (Int) -> Unit,
+ nodeShortName: String,
+ onExportData: (android.net.Uri) -> Unit,
+) {
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
+
+ val exportRangeTestLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ it.data?.data?.let { uri -> onExportData(uri) }
+ }
+ }
+
+ val exportDataLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ it.data?.data?.let { uri -> onExportData(uri) }
+ }
+ }
+
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
+ val cacheItems = remember {
+ (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map { it.toLong() to it.toString() }
+ }
+ DropDownPreference(
+ title = stringResource(Res.string.device_db_cache_limit),
+ enabled = true,
+ items = cacheItems,
+ selectedItem = cacheLimit.toLong(),
+ onItemSelected = { selected -> onSetCacheLimit(selected.toInt()) },
+ summary = stringResource(Res.string.device_db_cache_limit_summary),
+ )
+
+ ListItem(
+ text = stringResource(Res.string.save_rangetest),
+ leadingIcon = Icons.Rounded.Output,
+ trailingIcon = null,
+ ) {
+ val intent =
+ Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/csv"
+ putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeShortName}_$timestamp.csv")
+ }
+ exportRangeTestLauncher.launch(intent)
+ }
+
+ ListItem(
+ text = stringResource(Res.string.export_data_csv),
+ leadingIcon = Icons.Rounded.Output,
+ trailingIcon = null,
+ ) {
+ val intent =
+ Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/csv"
+ putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeShortName}_$timestamp.csv")
+ }
+ exportDataLauncher.launch(intent)
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PersistenceSectionPreview() {
+ AppTheme { PersistenceSection(cacheLimit = 100, onSetCacheLimit = {}, nodeShortName = "TEST", onExportData = {}) }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
new file mode 100644
index 000000000..cecdc27b8
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
@@ -0,0 +1,113 @@
+/*
+ * 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.feature.settings.component
+
+import android.Manifest
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.rounded.LocationOn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.gpsDisabled
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.analytics_okay
+import org.meshtastic.core.resources.app_settings
+import org.meshtastic.core.resources.location_disabled
+import org.meshtastic.core.resources.provide_location_to_mesh
+import org.meshtastic.core.ui.component.SwitchListItem
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.util.showToast
+
+/** Section managing privacy settings like analytics and location sharing. */
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun PrivacySection(
+ analyticsAvailable: Boolean,
+ analyticsEnabled: Boolean,
+ onToggleAnalytics: () -> Unit,
+ provideLocation: Boolean,
+ onToggleLocation: (Boolean) -> Unit,
+ homoglyphEnabled: Boolean,
+ onToggleHomoglyph: () -> Unit,
+ startProvideLocation: () -> Unit,
+ stopProvideLocation: () -> Unit,
+) {
+ val context = LocalContext.current
+ val locationPermissionsState =
+ rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
+ val isGpsDisabled = context.gpsDisabled()
+
+ LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
+ if (provideLocation) {
+ if (locationPermissionsState.allPermissionsGranted) {
+ if (!isGpsDisabled) {
+ startProvideLocation()
+ } else {
+ context.showToast(Res.string.location_disabled)
+ }
+ } else {
+ locationPermissionsState.launchMultiplePermissionRequest()
+ }
+ } else {
+ stopProvideLocation()
+ }
+ }
+
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
+ if (analyticsAvailable) {
+ SwitchListItem(
+ text = stringResource(Res.string.analytics_okay),
+ checked = analyticsEnabled,
+ leadingIcon = Icons.Default.BugReport,
+ onClick = onToggleAnalytics,
+ )
+ }
+
+ SwitchListItem(
+ text = stringResource(Res.string.provide_location_to_mesh),
+ leadingIcon = Icons.Rounded.LocationOn,
+ enabled = !isGpsDisabled,
+ checked = provideLocation,
+ onClick = { onToggleLocation(!provideLocation) },
+ )
+
+ HomoglyphSetting(homoglyphEncodingEnabled = homoglyphEnabled, onToggle = onToggleHomoglyph)
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PrivacySectionPreview() {
+ AppTheme {
+ PrivacySection(
+ analyticsAvailable = true,
+ analyticsEnabled = true,
+ onToggleAnalytics = {},
+ provideLocation = true,
+ onToggleLocation = {},
+ homoglyphEnabled = false,
+ onToggleHomoglyph = {},
+ startProvideLocation = {},
+ stopProvideLocation = {},
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
index 51ca46704..db9cd8fd5 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
@@ -40,7 +40,7 @@ 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.database.entity.NodeEntity
+import org.meshtastic.core.database.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clean_node_database_description
import org.meshtastic.core.resources.clean_node_database_title
@@ -150,7 +150,7 @@ private fun UnknownNodesFilter(onlyUnknownNodes: Boolean, onCheckedChanged: (Boo
* @param nodesToDelete The list of nodes to be deleted.
*/
@Composable
-private fun NodesDeletionPreview(nodesToDelete: List) {
+private fun NodesDeletionPreview(nodesToDelete: List) {
Text(
stringResource(Res.string.nodes_queued_for_deletion, nodesToDelete.size),
modifier = Modifier.padding(bottom = 16.dp),
@@ -160,8 +160,6 @@ private fun NodesDeletionPreview(nodesToDelete: List) {
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
) {
- nodesToDelete.forEach { node ->
- NodeChip(node = node.toModel(), modifier = Modifier.padding(end = 8.dp, bottom = 8.dp))
- }
+ nodesToDelete.forEach { node -> NodeChip(node = node, modifier = Modifier.padding(end = 8.dp, bottom = 8.dp)) }
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt
index 344ee0890..d17df93ff 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt
@@ -24,16 +24,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.are_you_sure
import org.meshtastic.core.resources.clean_node_database_confirmation
import org.meshtastic.core.resources.clean_now
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
-import kotlin.time.Duration.Companion.days
private const val MIN_DAYS_THRESHOLD = 7f
@@ -45,8 +43,7 @@ private const val MIN_DAYS_THRESHOLD = 7f
class CleanNodeDatabaseViewModel
@Inject
constructor(
- private val nodeRepository: NodeRepository,
- private val serviceRepository: ServiceRepository,
+ private val cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase,
private val alertManager: AlertManager,
) : ViewModel() {
private val _olderThanDays = MutableStateFlow(30f)
@@ -55,7 +52,7 @@ constructor(
private val _onlyUnknownNodes = MutableStateFlow(false)
val onlyUnknownNodes = _onlyUnknownNodes.asStateFlow()
- private val _nodesToDelete = MutableStateFlow>(emptyList())
+ private val _nodesToDelete = MutableStateFlow>(emptyList())
val nodesToDelete = _nodesToDelete.asStateFlow()
fun onOlderThanDaysChanged(value: Float) {
@@ -69,40 +66,15 @@ constructor(
}
}
- /**
- * Updates the list of nodes to be deleted based on the current filter criteria. The logic is as follows:
- * - The "older than X days" filter (controlled by the slider) is always active.
- * - If "only unknown nodes" is also enabled, nodes that are BOTH unknown AND older than X days are selected.
- * - If "only unknown nodes" is not enabled, all nodes older than X days are selected.
- * - Nodes with an associated public key (PKI) heard from within the last 7 days are always excluded from deletion.
- * - Nodes marked as ignored or favorite are always excluded from deletion.
- */
+ /** Updates the list of nodes to be deleted based on the current filter criteria. */
fun getNodesToDelete() {
viewModelScope.launch {
- val onlyUnknownEnabled = _onlyUnknownNodes.value
- val currentTimeSeconds = nowSeconds
- val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
- val olderThanTimestamp = currentTimeSeconds - _olderThanDays.value.toInt().days.inWholeSeconds
-
- val initialNodesToConsider =
- if (onlyUnknownEnabled) {
- // Both "older than X days" and "only unknown nodes" filters apply
- val olderNodes = nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
- val unknownNodes = nodeRepository.getUnknownNodes()
- olderNodes.filter { itNode -> unknownNodes.any { unknownNode -> itNode.num == unknownNode.num } }
- } else {
- // Only "older than X days" filter applies
- nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
- }
-
_nodesToDelete.value =
- initialNodesToConsider.filterNot { node ->
- // Exclude nodes with PKI heard in the last 7 days
- (node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) ||
- // Exclude ignored or favorite nodes
- node.isIgnored ||
- node.isFavorite
- }
+ cleanNodeDatabaseUseCase.getNodesToClean(
+ olderThanDays = _olderThanDays.value,
+ onlyUnknownNodes = _onlyUnknownNodes.value,
+ currentTimeSeconds = nowSeconds,
+ )
}
}
@@ -126,16 +98,7 @@ constructor(
fun cleanNodes() {
viewModelScope.launch {
val nodeNums = _nodesToDelete.value.map { it.num }
- if (nodeNums.isNotEmpty()) {
- nodeRepository.deleteNodes(nodeNums)
-
- val service = serviceRepository.meshService
- if (service != null) {
- for (nodeNum in nodeNums) {
- service.removeByNodenum(service.packetId, nodeNum)
- }
- }
- }
+ cleanNodeDatabaseUseCase.cleanNodes(nodeNums)
// Clear the list after deletion or if it was empty
_nodesToDelete.value = emptyList()
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
index b87987539..3bae7ef2b 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
@@ -18,8 +18,6 @@ package org.meshtastic.feature.settings.radio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
@@ -35,16 +33,11 @@ import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.SystemUpdate
import androidx.compose.material.icons.rounded.Upload
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
@@ -72,10 +65,9 @@ import org.meshtastic.core.resources.shutdown
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
+import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun RadioConfigItemList(
state: RadioConfigState,
@@ -89,130 +81,135 @@ fun RadioConfigItemList(
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
- if (isManaged) {
- ManagedMessage()
- }
- ConfigRoute.radioConfigRoutes.forEach {
- ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
- }
- }
-
- ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
- if (isManaged) {
- ManagedMessage()
- }
- ListItem(
- text = stringResource(Res.string.device_configuration),
- leadingIcon = Icons.Rounded.AppSettingsAlt,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- enabled = enabled,
- ) {
- onNavigate(SettingsRoutes.DeviceConfiguration)
- }
- }
-
- ExpressiveSection(title = stringResource(Res.string.module_settings)) {
- if (isManaged) {
- ManagedMessage()
- }
- ListItem(
- text = stringResource(Res.string.module_settings),
- leadingIcon = Icons.Rounded.Settings,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- enabled = enabled,
- ) {
- onNavigate(SettingsRoutes.ModuleConfiguration)
- }
- }
+ RadioConfigSection(isManaged, enabled, onRouteClick)
+ DeviceConfigSection(isManaged, enabled, onNavigate)
+ ModuleSettingsSection(isManaged, enabled, onNavigate)
if (state.isLocal) {
- ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
- if (isManaged) {
- ManagedMessage()
- }
-
- ListItem(
- text = stringResource(Res.string.import_configuration),
- leadingIcon = Icons.Rounded.Download,
- enabled = enabled,
- onClick = onImport,
- )
- ListItem(
- text = stringResource(Res.string.export_configuration),
- leadingIcon = Icons.Rounded.Upload,
- enabled = enabled,
- onClick = onExport,
- )
- }
+ BackupRestoreSection(isManaged, enabled, onImport, onExport)
}
- ExpressiveSection(title = stringResource(Res.string.administration)) {
- ListItem(
- text = stringResource(Res.string.administration),
- leadingIcon = Icons.Rounded.AdminPanelSettings,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- leadingIconTint = MaterialTheme.colorScheme.error,
- textColor = MaterialTheme.colorScheme.error,
- trailingIconTint = MaterialTheme.colorScheme.error,
- enabled = enabled,
- ) {
- onNavigate(SettingsRoutes.Administration)
- }
- }
+ AdministrationSection(enabled, onNavigate)
if (state.isLocal) {
- ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
- if (isManaged) {
- ManagedMessage()
- }
-
- if (isOtaCapable) {
- ListItem(
- text = stringResource(Res.string.firmware_update_title),
- leadingIcon = Icons.Rounded.SystemUpdate,
- enabled = enabled,
- onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
- )
- }
-
- ListItem(
- text = stringResource(Res.string.clean_node_database_title),
- leadingIcon = Icons.Rounded.CleaningServices,
- enabled = enabled,
- onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
- )
-
- ListItem(
- text = stringResource(Res.string.debug_panel),
- leadingIcon = Icons.Rounded.BugReport,
- enabled = enabled,
- onClick = { onNavigate(SettingsRoutes.DebugPanel) },
- )
- }
+ AdvancedSection(isManaged, isOtaCapable, enabled, onNavigate)
}
}
}
@Composable
-fun ExpressiveSection(
- title: String,
- modifier: Modifier = Modifier,
- titleColor: Color = MaterialTheme.colorScheme.primary,
- content: @Composable ColumnScope.() -> Unit,
-) {
- Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Text(
- text = title,
- modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = titleColor,
+private fun RadioConfigSection(isManaged: Boolean, enabled: Boolean, onRouteClick: (Enum<*>) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+ ConfigRoute.radioConfigRoutes.forEach {
+ ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
+ }
+ }
+}
+
+@Composable
+private fun DeviceConfigSection(isManaged: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+ ListItem(
+ text = stringResource(Res.string.device_configuration),
+ leadingIcon = Icons.Rounded.AppSettingsAlt,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.DeviceConfiguration)
+ }
+ }
+}
+
+@Composable
+private fun ModuleSettingsSection(isManaged: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.module_settings)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+ ListItem(
+ text = stringResource(Res.string.module_settings),
+ leadingIcon = Icons.Rounded.Settings,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.ModuleConfiguration)
+ }
+ }
+}
+
+@Composable
+private fun BackupRestoreSection(isManaged: Boolean, enabled: Boolean, onImport: () -> Unit, onExport: () -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+
+ ListItem(
+ text = stringResource(Res.string.import_configuration),
+ leadingIcon = Icons.Rounded.Download,
+ enabled = enabled,
+ onClick = onImport,
)
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
- content = content,
+ ListItem(
+ text = stringResource(Res.string.export_configuration),
+ leadingIcon = Icons.Rounded.Upload,
+ enabled = enabled,
+ onClick = onExport,
+ )
+ }
+}
+
+@Composable
+private fun AdministrationSection(enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.administration)) {
+ ListItem(
+ text = stringResource(Res.string.administration),
+ leadingIcon = Icons.Rounded.AdminPanelSettings,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ leadingIconTint = MaterialTheme.colorScheme.error,
+ textColor = MaterialTheme.colorScheme.error,
+ trailingIconTint = MaterialTheme.colorScheme.error,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.Administration)
+ }
+ }
+}
+
+@Composable
+private fun AdvancedSection(isManaged: Boolean, isOtaCapable: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+
+ if (isOtaCapable) {
+ ListItem(
+ text = stringResource(Res.string.firmware_update_title),
+ leadingIcon = Icons.Rounded.SystemUpdate,
+ enabled = enabled,
+ onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
+ )
+ }
+
+ ListItem(
+ text = stringResource(Res.string.clean_node_database_title),
+ leadingIcon = Icons.Rounded.CleaningServices,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
+ )
+
+ ListItem(
+ text = stringResource(Res.string.debug_panel),
+ leadingIcon = Icons.Rounded.BugReport,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.DebugPanel) },
)
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 2cb947c8f..bc61b70c4 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -21,8 +21,6 @@ import android.app.Application
import android.content.pm.PackageManager
import android.location.Location
import android.net.Uri
-import android.os.RemoteException
-import android.util.Base64
import androidx.annotation.RequiresPermission
import androidx.core.content.ContextCompat
import androidx.lifecycle.SavedStateHandle
@@ -44,15 +42,23 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
-import org.json.JSONObject
-import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.LocationRepository
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.MyNodeEntity
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.database.model.getStringResFrom
+import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
+import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
+import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Position
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
@@ -61,8 +67,6 @@ import org.meshtastic.core.prefs.map.MapConsentPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.cant_shutdown
-import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.feature.settings.navigation.ConfigRoute
@@ -79,8 +83,6 @@ import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
-import org.meshtastic.proto.PortNum
-import org.meshtastic.proto.Routing
import org.meshtastic.proto.User
import java.io.FileOutputStream
import javax.inject.Inject
@@ -119,20 +121,26 @@ constructor(
private val mapConsentPrefs: MapConsentPrefs,
private val analyticsPrefs: AnalyticsPrefs,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
+ private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase,
+ private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase,
+ private val importProfileUseCase: ImportProfileUseCase,
+ private val exportProfileUseCase: ExportProfileUseCase,
+ private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
+ private val installProfileUseCase: InstallProfileUseCase,
+ private val radioConfigUseCase: RadioConfigUseCase,
+ private val adminActionsUseCase: AdminActionsUseCase,
+ private val processRadioResponseUseCase: ProcessRadioResponseUseCase,
) : ViewModel() {
- private val meshService: IMeshService?
- get() = serviceRepository.meshService
-
var analyticsAllowedFlow = analyticsPrefs.getAnalyticsAllowedChangesFlow()
fun toggleAnalyticsAllowed() {
- analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
+ toggleAnalyticsUseCase()
}
val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.getHomoglyphEncodingEnabledChangesFlow()
fun toggleHomoglyphCharactersEncodingEnabled() {
- homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
+ toggleHomoglyphEncodingUseCase()
}
private val destNum =
@@ -234,52 +242,30 @@ constructor(
Logger.d { "RadioConfigViewModel cleared" }
}
- private fun request(destNum: Int, requestAction: suspend (IMeshService, Int, Int) -> Unit, errorMessage: String) =
- viewModelScope.launch {
- meshService?.let { service ->
- val packetId = service.getPacketId()
- try {
- requestAction(service, packetId, destNum)
- requestIds.update { it.apply { add(packetId) } }
- _radioConfigState.update { state ->
- if (state.responseState is ResponseState.Loading) {
- val total = maxOf(requestIds.value.size, state.responseState.total)
- state.copy(responseState = state.responseState.copy(total = total))
- } else {
- state.copy(
- route = "", // setter (response is PortNum.ROUTING_APP)
- responseState = ResponseState.Loading(),
- )
- }
- }
- } catch (ex: RemoteException) {
- Logger.e { "$errorMessage: ${ex.message}" }
- }
- }
- }
-
fun setOwner(user: User) {
- setRemoteOwner(destNode.value?.num ?: return, user)
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch {
+ _radioConfigState.update { it.copy(userConfig = user) }
+ val packetId = radioConfigUseCase.setOwner(destNum, user)
+ registerRequestId(packetId)
+ }
}
- private fun setRemoteOwner(destNum: Int, user: User) = request(
- destNum,
- { service, packetId, _ ->
- _radioConfigState.update { it.copy(userConfig = user) }
- service.setRemoteOwner(packetId, destNum, user.encode())
- },
- "Request setOwner error",
- )
-
- private fun getOwner(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRemoteOwner(packetId, dest) },
- "Request getOwner error",
- )
+ private fun getOwner(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getOwner(destNum)
+ registerRequestId(packetId)
+ }
+ }
fun updateChannels(new: List, old: List) {
val destNum = destNode.value?.num ?: return
- getChannelList(new, old).forEach { setRemoteChannel(destNum, it) }
+ getChannelList(new, old).forEach { channel ->
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel)
+ registerRequestId(packetId)
+ }
+ }
if (destNum == myNodeNum) {
viewModelScope.launch {
@@ -290,25 +276,16 @@ constructor(
_radioConfigState.update { it.copy(channelList = new) }
}
- private fun setRemoteChannel(destNum: Int, channel: Channel) = request(
- destNum,
- { service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.encode()) },
- "Request setRemoteChannel error",
- )
-
- private fun getChannel(destNum: Int, index: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) },
- "Request getChannel error",
- )
-
- fun setConfig(config: Config) {
- setRemoteConfig(destNode.value?.num ?: return, config)
+ private fun getChannel(destNum: Int, index: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getChannel(destNum, index)
+ registerRequestId(packetId)
+ }
}
- private fun setRemoteConfig(destNum: Int, config: Config) = request(
- destNum,
- { service, packetId, dest ->
+ fun setConfig(config: Config) {
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch {
_radioConfigState.update { state ->
state.copy(
radioConfig =
@@ -324,24 +301,22 @@ constructor(
),
)
}
- service.setRemoteConfig(packetId, dest, config.encode())
- },
- "Request setConfig error",
- )
-
- private fun getConfig(destNum: Int, configType: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) },
- "Request getConfig error",
- )
-
- fun setModuleConfig(config: ModuleConfig) {
- setRemoteModuleConfig(destNode.value?.num ?: return, config)
+ val packetId = radioConfigUseCase.setConfig(destNum, config)
+ registerRequestId(packetId)
+ }
}
- private fun setRemoteModuleConfig(destNum: Int, config: ModuleConfig) = request(
- destNum,
- { service, packetId, dest ->
+ private fun getConfig(destNum: Int, configType: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getConfig(destNum, configType)
+ registerRequestId(packetId)
+ }
+ }
+
+ @Suppress("CyclomaticComplexMethod")
+ fun setModuleConfig(config: ModuleConfig) {
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch {
_radioConfigState.update { state ->
state.copy(
moduleConfig =
@@ -366,97 +341,78 @@ constructor(
),
)
}
- service.setModuleConfig(packetId, dest, config.encode())
- },
- "Request setModuleConfig error",
- )
+ val packetId = radioConfigUseCase.setModuleConfig(destNum, config)
+ registerRequestId(packetId)
+ }
+ }
- private fun getModuleConfig(destNum: Int, configType: Int) = request(
- destNum,
- { service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) },
- "Request getModuleConfig error",
- )
+ private fun getModuleConfig(destNum: Int, configType: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getModuleConfig(destNum, configType)
+ registerRequestId(packetId)
+ }
+ }
fun setRingtone(ringtone: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(ringtone = ringtone) }
- try {
- meshService?.setRingtone(destNum, ringtone)
- } catch (ex: RemoteException) {
- Logger.e { "Set ringtone error: ${ex.message}" }
- }
+ viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) }
}
- private fun getRingtone(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRingtone(packetId, dest) },
- "Request getRingtone error",
- )
+ private fun getRingtone(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getRingtone(destNum)
+ registerRequestId(packetId)
+ }
+ }
fun setCannedMessages(messages: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(cannedMessageMessages = messages) }
- try {
- meshService?.setCannedMessages(destNum, messages)
- } catch (ex: RemoteException) {
- Logger.e { "Set canned messages error: ${ex.message}" }
+ viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) }
+ }
+
+ private fun getCannedMessages(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getCannedMessages(destNum)
+ registerRequestId(packetId)
}
}
- private fun getCannedMessages(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getCannedMessages(packetId, dest) },
- "Request getCannedMessages error",
- )
+ private fun getDeviceConnectionStatus(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum)
+ registerRequestId(packetId)
+ }
+ }
- private fun getDeviceConnectionStatus(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getDeviceConnectionStatus(packetId, dest) },
- "Request getDeviceConnectionStatus error",
- )
+ private fun requestShutdown(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = adminActionsUseCase.shutdown(destNum)
+ registerRequestId(packetId)
+ }
+ }
- private fun requestShutdown(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.requestShutdown(packetId, dest) },
- "Request shutdown error",
- )
-
- private fun requestReboot(destNum: Int) =
- request(destNum, { service, packetId, dest -> service.requestReboot(packetId, dest) }, "Request reboot error")
+ private fun requestReboot(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = adminActionsUseCase.reboot(destNum)
+ registerRequestId(packetId)
+ }
+ }
private fun requestFactoryReset(destNum: Int) {
- request(
- destNum,
- { service, packetId, dest -> service.requestFactoryReset(packetId, dest) },
- "Request factory reset error",
- )
- if (destNum == myNodeNum) {
- viewModelScope.launch {
- // Clear the service's in-memory node cache first so screens refresh immediately.
- val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty()
- meshService?.let { service ->
- existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) }
- }
- nodeRepository.clearNodeDB()
- }
+ viewModelScope.launch {
+ val isLocal = (destNum == myNodeNum)
+ val packetId = adminActionsUseCase.factoryReset(destNum, isLocal)
+ registerRequestId(packetId)
}
}
private fun requestNodedbReset(destNum: Int, preserveFavorites: Boolean) {
- request(
- destNum,
- { service, packetId, dest -> service.requestNodedbReset(packetId, dest, preserveFavorites) },
- "Request NodeDB reset error",
- )
- if (destNum == myNodeNum) {
- viewModelScope.launch {
- // Clear the service's in-memory node cache as well so UI updates immediately.
- val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty()
- meshService?.let { service ->
- existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) }
- }
- nodeRepository.clearNodeDB(preserveFavorites)
- }
+ viewModelScope.launch {
+ val isLocal = (destNum == myNodeNum)
+ val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
+ registerRequestId(packetId)
}
}
@@ -484,21 +440,18 @@ constructor(
fun setFixedPosition(position: Position) {
val destNum = destNode.value?.num ?: return
- try {
- meshService?.setFixedPosition(destNum, position)
- } catch (ex: RemoteException) {
- Logger.e { "Set fixed position error: ${ex.message}" }
- }
+ viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) }
}
- fun removeFixedPosition() = setFixedPosition(Position(0.0, 0.0, 0))
+ fun removeFixedPosition() {
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) }
+ }
fun importProfile(uri: Uri, onResult: (DeviceProfile) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
try {
- app.contentResolver.openInputStream(uri).use { inputStream ->
- val bytes = inputStream?.readBytes() ?: ByteArray(0)
- val protobuf = DeviceProfile.ADAPTER.decode(bytes)
- onResult(protobuf)
+ app.contentResolver.openInputStream(uri)?.use { inputStream ->
+ importProfileUseCase(inputStream).onSuccess(onResult).onFailure { throw it }
}
} catch (ex: Exception) {
Logger.e { "Import DeviceProfile error: ${ex.message}" }
@@ -506,104 +459,44 @@ constructor(
}
}
- fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch { writeToUri(uri, profile) }
-
- private suspend fun writeToUri(uri: Uri, message: com.squareup.wire.Message<*, *>) = withContext(Dispatchers.IO) {
- try {
- app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
- FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
- outputStream.write(message.encode())
+ fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ try {
+ app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
+ FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
+ exportProfileUseCase(outputStream, profile)
+ .onSuccess { setResponseStateSuccess() }
+ .onFailure { throw it }
+ }
}
+ } catch (ex: Exception) {
+ Logger.e { "Can't write file error: ${ex.message}" }
+ sendError(ex.customMessage)
}
- setResponseStateSuccess()
- } catch (ex: Exception) {
- Logger.e { "Can't write file error: ${ex.message}" }
- sendError(ex.customMessage)
}
}
- fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) =
- viewModelScope.launch { writeSecurityKeysJsonToUri(uri, securityConfig) }
-
- private val indentSpaces = 4
-
- private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: Config.SecurityConfig) =
+ fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) = viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
- val publicKeyBytes = securityConfig.public_key.toByteArray()
- val privateKeyBytes = securityConfig.private_key.toByteArray()
-
- // Convert byte arrays to Base64 strings for human readability in JSON
- val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
- val privateKeyBase64 = Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)
-
- // Create a JSON object
- val jsonObject =
- JSONObject().apply {
- put("timestamp", nowMillis)
- put("public_key", publicKeyBase64)
- put("private_key", privateKeyBase64)
- }
-
- // Convert JSON object to a string
- val jsonString = jsonObject.toString(indentSpaces)
-
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
- outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
+ exportSecurityConfigUseCase(outputStream, securityConfig)
+ .onSuccess { setResponseStateSuccess() }
+ .onFailure { throw it }
}
}
- setResponseStateSuccess()
} catch (ex: Exception) {
val errorMessage = "Can't write security keys JSON error: ${ex.message}"
Logger.e { errorMessage }
sendError(ex.customMessage)
}
}
+ }
fun installProfile(protobuf: DeviceProfile) {
val destNum = destNode.value?.num ?: return
- with(protobuf) {
- meshService?.beginEditSettings(destNum)
- if (long_name != null || short_name != null) {
- destNode.value?.user?.let {
- val user = it.copy(long_name = long_name ?: it.long_name, short_name = short_name ?: it.short_name)
- setOwner(user)
- }
- }
- config?.let { lc ->
- lc.device?.let { setConfig(Config(device = it)) }
- lc.position?.let { setConfig(Config(position = it)) }
- lc.power?.let { setConfig(Config(power = it)) }
- lc.network?.let { setConfig(Config(network = it)) }
- lc.display?.let { setConfig(Config(display = it)) }
- lc.lora?.let { setConfig(Config(lora = it)) }
- lc.bluetooth?.let { setConfig(Config(bluetooth = it)) }
- lc.security?.let { setConfig(Config(security = it)) }
- }
- if (fixed_position != null) {
- setFixedPosition(Position(fixed_position!!))
- }
- module_config?.let { lmc ->
- lmc.mqtt?.let { setModuleConfig(ModuleConfig(mqtt = it)) }
- lmc.serial?.let { setModuleConfig(ModuleConfig(serial = it)) }
- lmc.external_notification?.let { setModuleConfig(ModuleConfig(external_notification = it)) }
- lmc.store_forward?.let { setModuleConfig(ModuleConfig(store_forward = it)) }
- lmc.range_test?.let { setModuleConfig(ModuleConfig(range_test = it)) }
- lmc.telemetry?.let { setModuleConfig(ModuleConfig(telemetry = it)) }
- lmc.canned_message?.let { setModuleConfig(ModuleConfig(canned_message = it)) }
- lmc.audio?.let { setModuleConfig(ModuleConfig(audio = it)) }
- lmc.remote_hardware?.let { setModuleConfig(ModuleConfig(remote_hardware = it)) }
- lmc.neighbor_info?.let { setModuleConfig(ModuleConfig(neighbor_info = it)) }
- lmc.ambient_lighting?.let { setModuleConfig(ModuleConfig(ambient_lighting = it)) }
- lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
- lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
- lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
- lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) }
- lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
- }
- meshService?.commitEditSettings(destNum)
- }
+ viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) }
}
fun clearPacketResponse() {
@@ -686,6 +579,8 @@ constructor(
private fun sendError(id: StringResource) = setResponseStateError(UiText.Resource(id))
+ private fun sendError(error: UiText) = setResponseStateError(error)
+
private fun setResponseStateError(error: UiText) {
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
}
@@ -701,171 +596,156 @@ constructor(
}
}
- private fun processPacketResponse(packet: MeshPacket) {
- val data = packet.decoded ?: return
- if (data.request_id !in requestIds.value) return
- val route = radioConfigState.value.route
-
- val destNum = destNode.value?.num ?: return
- val debugMsg = "requestId: ${data.request_id.toUInt()} to: ${destNum.toUInt()} received %s"
-
- if (data.portnum == PortNum.ROUTING_APP) {
- val parsed = Routing.ADAPTER.decode(data.payload)
- Logger.d { debugMsg.format(parsed.error_reason?.name) }
- if (parsed.error_reason != Routing.Error.NONE) {
- sendError(getStringResFrom(parsed.error_reason?.value ?: 0))
- } else if (packet.from == destNum && route.isEmpty()) {
- requestIds.update { it.apply { remove(data.request_id) } }
- if (requestIds.value.isEmpty()) {
- setResponseStateSuccess()
- } else {
- incrementCompleted()
- }
+ private fun registerRequestId(packetId: Int) {
+ requestIds.update { it.apply { add(packetId) } }
+ _radioConfigState.update { state ->
+ if (state.responseState is ResponseState.Loading) {
+ val total = maxOf(requestIds.value.size, state.responseState.total)
+ state.copy(responseState = state.responseState.copy(total = total))
+ } else {
+ state.copy(
+ route = "", // setter (response is PortNum.ROUTING_APP)
+ responseState = ResponseState.Loading(),
+ )
}
}
- if (data.portnum == PortNum.ADMIN_APP) {
- val parsed = AdminMessage.ADAPTER.decode(data.payload)
- // Explicitly log the non-null field name for clarity
- val variant =
- when {
- parsed.get_device_metadata_response != null -> "get_device_metadata_response"
- parsed.get_channel_response != null -> "get_channel_response"
- parsed.get_owner_response != null -> "get_owner_response"
- parsed.get_config_response != null -> "get_config_response"
- parsed.get_module_config_response != null -> "get_module_config_response"
- parsed.get_canned_message_module_messages_response != null ->
- "get_canned_message_module_messages_response"
- parsed.get_ringtone_response != null -> "get_ringtone_response"
- parsed.get_device_connection_status_response != null -> "get_device_connection_status_response"
- else -> "unknown"
- }
- Logger.d { debugMsg.format(variant) }
- if (destNum != packet.from) {
- sendError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.")
- return
- }
- when {
- parsed.get_device_metadata_response != null -> {
- _radioConfigState.update { it.copy(metadata = parsed.get_device_metadata_response) }
- incrementCompleted()
- }
+ }
- parsed.get_channel_response != null -> {
- val response = parsed.get_channel_response!!
- // Stop once we get to the first disabled entry
- if (response.role != Channel.Role.DISABLED) {
- _radioConfigState.update { state ->
- state.copy(
- channelList =
- state.channelList.toMutableList().apply {
- val index = response.index
- val settings = response.settings ?: ChannelSettings()
- // Make sure list is large enough
- while (size <= index) add(ChannelSettings())
- set(index, settings)
- },
- )
- }
- incrementCompleted()
- val index = response.index
- if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
- // Not done yet, request next channel
- getChannel(destNum, index + 1)
- }
+ private fun processPacketResponse(packet: MeshPacket) {
+ val destNum = destNode.value?.num ?: return
+ val result = processRadioResponseUseCase(packet, destNum, requestIds.value) ?: return
+ val route = radioConfigState.value.route
+
+ when (result) {
+ is RadioResponseResult.Error -> sendError(result.message)
+ is RadioResponseResult.Success -> {
+ if (route.isEmpty()) {
+ val data = packet.decoded!!
+ requestIds.update { it.apply { remove(data.request_id) } }
+ if (requestIds.value.isEmpty()) {
+ setResponseStateSuccess()
} else {
- // Received last channel, update total and start channel editor
- setResponseStateTotal(response.index + 1)
+ incrementCompleted()
}
}
+ }
- parsed.get_owner_response != null -> {
- _radioConfigState.update { it.copy(userConfig = parsed.get_owner_response!!) }
- incrementCompleted()
- }
+ is RadioResponseResult.Metadata -> {
+ _radioConfigState.update { it.copy(metadata = result.metadata) }
+ incrementCompleted()
+ }
- parsed.get_config_response != null -> {
- val response = parsed.get_config_response!!
+ is RadioResponseResult.ChannelResponse -> {
+ val response = result.channel
+ // Stop once we get to the first disabled entry
+ if (response.role != Channel.Role.DISABLED) {
_radioConfigState.update { state ->
state.copy(
- radioConfig =
- state.radioConfig.copy(
- device = response.device ?: state.radioConfig.device,
- position = response.position ?: state.radioConfig.position,
- power = response.power ?: state.radioConfig.power,
- network = response.network ?: state.radioConfig.network,
- display = response.display ?: state.radioConfig.display,
- lora = response.lora ?: state.radioConfig.lora,
- bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
- security = response.security ?: state.radioConfig.security,
- ),
+ channelList =
+ state.channelList.toMutableList().apply {
+ val index = response.index
+ val settings = response.settings ?: ChannelSettings()
+ // Make sure list is large enough
+ while (size <= index) add(ChannelSettings())
+ set(index, settings)
+ },
)
}
incrementCompleted()
- }
-
- parsed.get_module_config_response != null -> {
- val response = parsed.get_module_config_response!!
- _radioConfigState.update { state ->
- state.copy(
- moduleConfig =
- state.moduleConfig.copy(
- mqtt = response.mqtt ?: state.moduleConfig.mqtt,
- serial = response.serial ?: state.moduleConfig.serial,
- external_notification =
- response.external_notification ?: state.moduleConfig.external_notification,
- store_forward = response.store_forward ?: state.moduleConfig.store_forward,
- range_test = response.range_test ?: state.moduleConfig.range_test,
- telemetry = response.telemetry ?: state.moduleConfig.telemetry,
- canned_message = response.canned_message ?: state.moduleConfig.canned_message,
- audio = response.audio ?: state.moduleConfig.audio,
- remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
- neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
- ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
- detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
- paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
- statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
- traffic_management =
- response.traffic_management ?: state.moduleConfig.traffic_management,
- tak = response.tak ?: state.moduleConfig.tak,
- ),
- )
+ val index = response.index
+ if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
+ // Not done yet, request next channel
+ getChannel(destNum, index + 1)
}
- incrementCompleted()
+ } else {
+ // Received last channel, update total and start channel editor
+ setResponseStateTotal(response.index + 1)
}
-
- parsed.get_canned_message_module_messages_response != null -> {
- _radioConfigState.update {
- it.copy(cannedMessageMessages = parsed.get_canned_message_module_messages_response!!)
- }
- incrementCompleted()
- }
-
- parsed.get_ringtone_response != null -> {
- _radioConfigState.update { it.copy(ringtone = parsed.get_ringtone_response!!) }
- incrementCompleted()
- }
-
- parsed.get_device_connection_status_response != null -> {
- _radioConfigState.update {
- it.copy(deviceConnectionStatus = parsed.get_device_connection_status_response!!)
- }
- incrementCompleted()
- }
-
- else -> Logger.d { "No custom processing needed for $parsed" }
}
- if (AdminRoute.entries.any { it.name == route }) {
- sendAdminRequest(destNum)
+ is RadioResponseResult.Owner -> {
+ _radioConfigState.update { it.copy(userConfig = result.user) }
+ incrementCompleted()
}
- requestIds.update { it.apply { remove(data.request_id) } }
- if (requestIds.value.isEmpty()) {
- if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
- clearPacketResponse()
- } else if (route.isEmpty()) {
- setResponseStateSuccess()
+ is RadioResponseResult.ConfigResponse -> {
+ val response = result.config
+ _radioConfigState.update { state ->
+ state.copy(
+ radioConfig =
+ state.radioConfig.copy(
+ device = response.device ?: state.radioConfig.device,
+ position = response.position ?: state.radioConfig.position,
+ power = response.power ?: state.radioConfig.power,
+ network = response.network ?: state.radioConfig.network,
+ display = response.display ?: state.radioConfig.display,
+ lora = response.lora ?: state.radioConfig.lora,
+ bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
+ security = response.security ?: state.radioConfig.security,
+ ),
+ )
}
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.ModuleConfigResponse -> {
+ val response = result.config
+ _radioConfigState.update { state ->
+ state.copy(
+ moduleConfig =
+ state.moduleConfig.copy(
+ mqtt = response.mqtt ?: state.moduleConfig.mqtt,
+ serial = response.serial ?: state.moduleConfig.serial,
+ external_notification =
+ response.external_notification ?: state.moduleConfig.external_notification,
+ store_forward = response.store_forward ?: state.moduleConfig.store_forward,
+ range_test = response.range_test ?: state.moduleConfig.range_test,
+ telemetry = response.telemetry ?: state.moduleConfig.telemetry,
+ canned_message = response.canned_message ?: state.moduleConfig.canned_message,
+ audio = response.audio ?: state.moduleConfig.audio,
+ remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
+ neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
+ ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
+ detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
+ paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
+ statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
+ traffic_management =
+ response.traffic_management ?: state.moduleConfig.traffic_management,
+ tak = response.tak ?: state.moduleConfig.tak,
+ ),
+ )
+ }
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.CannedMessages -> {
+ _radioConfigState.update { it.copy(cannedMessageMessages = result.messages) }
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.Ringtone -> {
+ _radioConfigState.update { it.copy(ringtone = result.ringtone) }
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.ConnectionStatus -> {
+ _radioConfigState.update { it.copy(deviceConnectionStatus = result.status) }
+ incrementCompleted()
+ }
+ }
+
+ if (AdminRoute.entries.any { it.name == route }) {
+ sendAdminRequest(destNum)
+ }
+
+ val requestId = packet.decoded?.request_id ?: return
+ requestIds.update { it.apply { remove(requestId) } }
+
+ if (requestIds.value.isEmpty()) {
+ if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
+ clearPacketResponse()
+ } else if (route.isEmpty()) {
+ setResponseStateSuccess()
}
}
}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt
index 1c1863346..8ffb10fae 100644
--- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt
@@ -28,6 +28,7 @@ import org.junit.runner.RunWith
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.use_homoglyph_characters_encoding
+import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Locale
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
new file mode 100644
index 000000000..9879d8903
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.feature.settings
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.database.DatabaseManager
+import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
+import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
+import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
+import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
+import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
+import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
+import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
+import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.prefs.ui.UiPrefs
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SettingsViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
+ private val radioController: RadioController = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val uiPrefs: UiPrefs = mockk(relaxed = true)
+ private val buildConfigProvider: BuildConfigProvider = mockk(relaxed = true)
+ private val databaseManager: DatabaseManager = mockk(relaxed = true)
+ private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true)
+
+ private val setThemeUseCase: SetThemeUseCase = mockk(relaxed = true)
+ private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase = mockk(relaxed = true)
+ private val setProvideLocationUseCase: SetProvideLocationUseCase = mockk(relaxed = true)
+ private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase = mockk(relaxed = true)
+ private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase = mockk(relaxed = true)
+ private val meshLocationUseCase: MeshLocationUseCase = mockk(relaxed = true)
+ private val exportDataUseCase: ExportDataUseCase = mockk(relaxed = true)
+ private val isOtaCapableUseCase: IsOtaCapableUseCase = mockk(relaxed = true)
+
+ private lateinit var viewModel: SettingsViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ // Return real StateFlows to avoid ClassCastException
+ every { databaseManager.cacheLimit } returns MutableStateFlow(100)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
+ every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig())
+ every { radioController.connectionState } returns
+ MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
+ every { isOtaCapableUseCase() } returns flowOf(false)
+
+ viewModel =
+ SettingsViewModel(
+ app = mockk(),
+ radioConfigRepository = radioConfigRepository,
+ radioController = radioController,
+ nodeRepository = nodeRepository,
+ uiPrefs = uiPrefs,
+ buildConfigProvider = buildConfigProvider,
+ databaseManager = databaseManager,
+ meshLogPrefs = meshLogPrefs,
+ setThemeUseCase = setThemeUseCase,
+ setAppIntroCompletedUseCase = setAppIntroCompletedUseCase,
+ setProvideLocationUseCase = setProvideLocationUseCase,
+ setDatabaseCacheLimitUseCase = setDatabaseCacheLimitUseCase,
+ setMeshLogSettingsUseCase = setMeshLogSettingsUseCase,
+ meshLocationUseCase = meshLocationUseCase,
+ exportDataUseCase = exportDataUseCase,
+ isOtaCapableUseCase = isOtaCapableUseCase,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `setTheme calls useCase`() {
+ viewModel.setTheme(1)
+ verify { setThemeUseCase(1) }
+ }
+
+ @Test
+ fun `setDbCacheLimit calls useCase`() {
+ viewModel.setDbCacheLimit(50)
+ verify { setDatabaseCacheLimitUseCase(50) }
+ }
+
+ @Test
+ fun `startProvidingLocation calls useCase`() {
+ viewModel.startProvidingLocation()
+ verify { meshLocationUseCase.startProvidingLocation() }
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
new file mode 100644
index 000000000..b7a256bf4
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.feature.settings.debugging
+
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.ui.util.AlertManager
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DebugViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true)
+ private val alertManager: AlertManager = mockk(relaxed = true)
+
+ private lateinit var viewModel: DebugViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ every { meshLogRepository.getAllLogs() } returns flowOf(emptyList())
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+ every { meshLogPrefs.retentionDays } returns 7
+ every { meshLogPrefs.loggingEnabled } returns true
+
+ viewModel =
+ DebugViewModel(
+ meshLogRepository = meshLogRepository,
+ nodeRepository = nodeRepository,
+ meshLogPrefs = meshLogPrefs,
+ alertManager = alertManager,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `setRetentionDays updates prefs and deletes old logs`() = runTest {
+ viewModel.setRetentionDays(14)
+
+ verify { meshLogPrefs.retentionDays = 14 }
+ coVerify { meshLogRepository.deleteLogsOlderThan(14) }
+ assertEquals(14, viewModel.retentionDays.value)
+ }
+
+ @Test
+ fun `setLoggingEnabled false deletes all logs`() = runTest {
+ viewModel.setLoggingEnabled(false)
+
+ verify { meshLogPrefs.loggingEnabled = false }
+ coVerify { meshLogRepository.deleteAll() }
+ assertEquals(false, viewModel.loggingEnabled.value)
+ }
+
+ @Test
+ fun `search filters results correctly`() = runTest {
+ val logs =
+ listOf(
+ DebugViewModel.UiMeshLog("1", "TypeA", "Date1", "Message Apple"),
+ DebugViewModel.UiMeshLog("2", "TypeB", "Date2", "Message Banana"),
+ )
+
+ viewModel.searchManager.updateMatches("Apple", logs)
+
+ val state = viewModel.searchState.value
+ assertEquals(true, state.hasMatches)
+ assertEquals(1, state.allMatches.size)
+ assertEquals(0, state.allMatches[0].logIndex)
+ }
+
+ @Test
+ fun `requestDeleteAllLogs shows alert`() {
+ viewModel.requestDeleteAllLogs()
+ verify { alertManager.showAlert(titleRes = any(), messageRes = any(), onConfirm = any()) }
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt
new file mode 100644
index 000000000..35fd61f2b
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt
@@ -0,0 +1,67 @@
+/*
+ * 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.feature.settings.filter
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.prefs.filter.FilterPrefs
+import org.meshtastic.core.service.filter.MessageFilterService
+
+class FilterSettingsViewModelTest {
+
+ private val filterPrefs: FilterPrefs = mockk(relaxed = true)
+ private val messageFilterService: MessageFilterService = mockk(relaxed = true)
+
+ private lateinit var viewModel: FilterSettingsViewModel
+
+ @Before
+ fun setUp() {
+ every { filterPrefs.filterEnabled } returns true
+ every { filterPrefs.filterWords } returns setOf("apple", "banana")
+
+ viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilterService = messageFilterService)
+ }
+
+ @Test
+ fun `setFilterEnabled updates prefs and state`() {
+ viewModel.setFilterEnabled(false)
+ verify { filterPrefs.filterEnabled = false }
+ assertEquals(false, viewModel.filterEnabled.value)
+ }
+
+ @Test
+ fun `addFilterWord updates prefs and rebuilds patterns`() {
+ viewModel.addFilterWord("cherry")
+
+ verify { filterPrefs.filterWords = any() }
+ verify { messageFilterService.rebuildPatterns() }
+ assertEquals(listOf("apple", "banana", "cherry"), viewModel.filterWords.value)
+ }
+
+ @Test
+ fun `removeFilterWord updates prefs and rebuilds patterns`() {
+ viewModel.removeFilterWord("apple")
+
+ verify { filterPrefs.filterWords = any() }
+ verify { messageFilterService.rebuildPatterns() }
+ assertEquals(listOf("banana"), viewModel.filterWords.value)
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt
new file mode 100644
index 000000000..07beee89d
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.feature.settings.radio
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
+import org.meshtastic.core.ui.util.AlertManager
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class CleanNodeDatabaseViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase
+ private lateinit var alertManager: AlertManager
+ private lateinit var viewModel: CleanNodeDatabaseViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ cleanNodeDatabaseUseCase = mockk(relaxed = true)
+ alertManager = mockk(relaxed = true)
+ viewModel = CleanNodeDatabaseViewModel(cleanNodeDatabaseUseCase, alertManager)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `getNodesToDelete updates state`() = runTest {
+ val nodes = listOf(Node(num = 1), Node(num = 2))
+ coEvery { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
+
+ viewModel.getNodesToDelete()
+ advanceUntilIdle()
+
+ assertEquals(nodes, viewModel.nodesToDelete.value)
+ }
+
+ @Test
+ fun `cleanNodes calls useCase and clears state`() = runTest {
+ val nodes = listOf(Node(num = 1))
+ coEvery { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
+ viewModel.getNodesToDelete()
+ advanceUntilIdle()
+
+ viewModel.cleanNodes()
+ advanceUntilIdle()
+
+ coVerify { cleanNodeDatabaseUseCase.cleanNodes(listOf(1)) }
+ assertEquals(0, viewModel.nodesToDelete.value.size)
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
new file mode 100644
index 000000000..cc45c7075
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
@@ -0,0 +1,241 @@
+/*
+ * Copyright (c) 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.feature.settings.radio
+
+import androidx.lifecycle.SavedStateHandle
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.LocationRepository
+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.model.Node
+import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
+import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
+import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
+import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
+import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
+import org.meshtastic.core.prefs.map.MapConsentPrefs
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.DeviceProfile
+import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.MeshPacket
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class RadioConfigViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
+ private val packetRepository: PacketRepository = mockk(relaxed = true)
+ private val serviceRepository: ServiceRepository = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val locationRepository: LocationRepository = mockk(relaxed = true)
+ private val mapConsentPrefs: MapConsentPrefs = mockk(relaxed = true)
+ private val analyticsPrefs: AnalyticsPrefs = mockk(relaxed = true)
+ private val homoglyphEncodingPrefs: HomoglyphPrefs = mockk(relaxed = true)
+ private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mockk(relaxed = true)
+ private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mockk(relaxed = true)
+ private val importProfileUseCase: ImportProfileUseCase = mockk(relaxed = true)
+ private val exportProfileUseCase: ExportProfileUseCase = mockk(relaxed = true)
+ private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mockk(relaxed = true)
+ private val installProfileUseCase: InstallProfileUseCase = mockk(relaxed = true)
+ private val radioConfigUseCase: RadioConfigUseCase = mockk(relaxed = true)
+ private val adminActionsUseCase: AdminActionsUseCase = mockk(relaxed = true)
+ private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mockk(relaxed = true)
+
+ private lateinit var viewModel: RadioConfigViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+ every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile())
+ every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
+ every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
+ every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig())
+ every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
+ every { serviceRepository.connectionState } returns
+ MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+
+ viewModel = createViewModel()
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun createViewModel() = RadioConfigViewModel(
+ savedStateHandle = SavedStateHandle(),
+ app = mockk(),
+ radioConfigRepository = radioConfigRepository,
+ packetRepository = packetRepository,
+ serviceRepository = serviceRepository,
+ nodeRepository = nodeRepository,
+ locationRepository = locationRepository,
+ mapConsentPrefs = mapConsentPrefs,
+ analyticsPrefs = analyticsPrefs,
+ homoglyphEncodingPrefs = homoglyphEncodingPrefs,
+ toggleAnalyticsUseCase = toggleAnalyticsUseCase,
+ toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase,
+ importProfileUseCase = importProfileUseCase,
+ exportProfileUseCase = exportProfileUseCase,
+ exportSecurityConfigUseCase = exportSecurityConfigUseCase,
+ installProfileUseCase = installProfileUseCase,
+ radioConfigUseCase = radioConfigUseCase,
+ adminActionsUseCase = adminActionsUseCase,
+ processRadioResponseUseCase = processRadioResponseUseCase,
+ )
+
+ @Test
+ fun `setConfig updates state and calls useCase`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+ viewModel = createViewModel()
+
+ val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
+ coEvery { radioConfigUseCase.setConfig(123, any()) } returns 42
+
+ viewModel.setConfig(config)
+
+ val state = viewModel.radioConfigState.value
+ assertEquals(Config.DeviceConfig.Role.ROUTER, state.radioConfig.device?.role)
+ coVerify { radioConfigUseCase.setConfig(123, config) }
+ }
+
+ @Test
+ fun `processPacketResponse updates state on metadata result`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+
+ val packet = MeshPacket()
+ val metadata = DeviceMetadata(firmware_version = "3.0.0")
+ val packetFlow = MutableSharedFlow()
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Metadata(metadata)
+
+ viewModel = createViewModel()
+
+ packetFlow.emit(packet)
+
+ val state = viewModel.radioConfigState.value
+ assertEquals("3.0.0", state.metadata?.firmware_version)
+ }
+
+ @Test
+ fun `setOwner calls useCase`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+ viewModel = createViewModel()
+
+ val user = org.meshtastic.proto.User(long_name = "Test")
+ coEvery { radioConfigUseCase.setOwner(123, any()) } returns 42
+
+ viewModel.setOwner(user)
+
+ coVerify { radioConfigUseCase.setOwner(123, user) }
+ }
+
+ @Test
+ fun `updateChannels calls useCase for each changed channel`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+ viewModel = createViewModel()
+
+ val old = listOf(ChannelSettings(name = "Old"))
+ val new = listOf(ChannelSettings(name = "New"))
+
+ coEvery { radioConfigUseCase.setRemoteChannel(123, any()) } returns 42
+
+ viewModel.updateChannels(new, old)
+
+ coVerify { radioConfigUseCase.setRemoteChannel(123, any()) }
+ assertEquals(new, viewModel.radioConfigState.value.channelList)
+ }
+
+ @Test
+ fun `setResponseStateLoading for REBOOT calls useCase after packet response`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+
+ val packetFlow = MutableSharedFlow()
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success
+
+ viewModel = createViewModel()
+
+ coEvery { adminActionsUseCase.reboot(123) } returns 42
+
+ viewModel.setResponseStateLoading(AdminRoute.REBOOT)
+
+ // Emit a packet to trigger processPacketResponse -> sendAdminRequest
+ packetFlow.emit(MeshPacket())
+
+ coVerify { adminActionsUseCase.reboot(123) }
+ }
+
+ @Test
+ fun `setResponseStateLoading for FACTORY_RESET calls useCase after packet response`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+
+ val packetFlow = MutableSharedFlow()
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success
+
+ viewModel = createViewModel()
+
+ coEvery { adminActionsUseCase.factoryReset(123, any()) } returns 42
+
+ viewModel.setResponseStateLoading(AdminRoute.FACTORY_RESET)
+
+ // Emit a packet to trigger processPacketResponse -> sendAdminRequest
+ packetFlow.emit(MeshPacket())
+
+ coVerify { adminActionsUseCase.factoryReset(123, any()) }
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index 2b135dd18..b0a71dbe3 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,59 +1,38 @@
+## For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
#
-# Copyright (c) 2025 Meshtastic LLC
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+# Default value: -Xmx1024m -XX:MaxPermSize=256m
+# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
#
-# 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 .
-#
-
-# Project-wide Gradle settings.
-org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
-
-# Parallelism & Caching
-org.gradle.parallel=true
-org.gradle.caching=true
-org.gradle.configuration-cache=true
-org.gradle.isolated-projects=true
-org.gradle.vfs.watch=true
-org.gradle.configureondemand=false
-
-# Kotlin Optimization
-# Parallelize Kotlin tasks within a single project (great for KMP)
-kotlin.parallel.tasks.in.project=true
-# Give Kotlin daemon enough breathing room
-kotlin.daemon.jvm.options=-Xmx4g -XX:+UseParallelGC
-kotlin.code.style=official
-
-# Android (AGP) Optimization
-android.useAndroidX=true
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+#Sat Feb 28 21:28:07 CST 2026
android.enableJetifier=false
-android.nonTransitiveRClass=true
-# More aggressive R8 optimizations
android.enableR8.fullMode=true
-# Parallel lint analysis
android.experimental.lint.analysisPerComponent=true
-
-# KSP 2 Configuration
-ksp.useKSP2=true
-ksp.run.in.process=true
-ksp.incremental=true
-ksp.incremental.classpath=true
-ksp.incremental.intermodule=true
-
-# UI & Analysis
+android.newDsl=false
+android.nonTransitiveRClass=true
+android.useAndroidX=true
dependency.analysis.print.build.health=true
enableComposeCompilerMetrics=false
enableComposeCompilerReports=false
-
-# Housekeeping
+kotlin.code.style=official
+kotlin.daemon.jvm.options=-Xmx4g -XX\:+UseParallelGC
+kotlin.parallel.tasks.in.project=true
+ksp.incremental=true
+ksp.incremental.classpath=true
+ksp.incremental.intermodule=true
+ksp.run.in.process=true
+ksp.useKSP2=true
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+org.gradle.configureondemand=false
+org.gradle.isolated-projects=true
+org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.vfs.watch=true
org.gradle.welcome=never
-android.newDsl=false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d51b3cce8..48fe82c7c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,6 +26,7 @@ ktlint = "1.7.1"
kover = "0.9.7"
mockk = "1.14.9"
testRetry = "1.6.4"
+turbine = "1.1.0"
# Compose Multiplatform
compose-multiplatform = "1.11.0-alpha03"
@@ -108,6 +109,7 @@ androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "
androidx-savedstate-compose = { module = "androidx.savedstate:savedstate-compose", version.ref = "savedstate" }
androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "savedstate" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
+androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.1" }
# AndroidX Compose
androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.02.01" }
@@ -178,6 +180,7 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core",
junit = { module = "junit:junit", version = "4.13.2" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
# Other
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 16c8309fb..0db4cf6c0 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -26,6 +26,7 @@ include(
":core:database",
":core:datastore",
":core:di",
+ ":core:domain",
":core:model",
":core:navigation",
":core:network",
From 728c30031540ae77f5d850c4d075ec710a7339fd Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 2 Mar 2026 15:47:19 -0600
Subject: [PATCH 044/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4683)
---
.../src/commonMain/composeResources/values-et/strings.xml | 1 +
core/service/README.md | 1 +
feature/messaging/README.md | 2 ++
feature/settings/README.md | 1 +
4 files changed, 5 insertions(+)
diff --git a/core/resources/src/commonMain/composeResources/values-et/strings.xml b/core/resources/src/commonMain/composeResources/values-et/strings.xml
index e07369ea9..e0a24d297 100644
--- a/core/resources/src/commonMain/composeResources/values-et/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-et/strings.xml
@@ -311,6 +311,7 @@
Otsesõnum
NodeDB lähtestamine
Kohale toimetatud
+ Seadete rakendamise ajal võib seadme ühendus katkeda ja taaskäivituda.
Viga
Eira
Eemalda ignoreeritute hulgast
diff --git a/core/service/README.md b/core/service/README.md
index 197c5e1f0..2b38a9171 100644
--- a/core/service/README.md
+++ b/core/service/README.md
@@ -25,6 +25,7 @@ graph TB
:core:service[service]:::android-library
:core:service --> :core:api
:core:service -.-> :core:common
+ :core:service -.-> :core:data
:core:service -.-> :core:database
:core:service -.-> :core:model
:core:service -.-> :core:prefs
diff --git a/feature/messaging/README.md b/feature/messaging/README.md
index 59d8736bb..c323ea7a2 100644
--- a/feature/messaging/README.md
+++ b/feature/messaging/README.md
@@ -27,8 +27,10 @@ A security-focused utility that detects and transforms homoglyphs (visually simi
graph TB
:feature:messaging[messaging]:::android-feature
:feature:messaging -.-> :core:analytics
+ :feature:messaging -.-> :core:common
:feature:messaging -.-> :core:data
:feature:messaging -.-> :core:database
+ :feature:messaging -.-> :core:domain
:feature:messaging -.-> :core:model
:feature:messaging -.-> :core:navigation
:feature:messaging -.-> :core:prefs
diff --git a/feature/settings/README.md b/feature/settings/README.md
index 78074fb4d..cc5c584bb 100644
--- a/feature/settings/README.md
+++ b/feature/settings/README.md
@@ -29,6 +29,7 @@ graph TB
:feature:settings -.-> :core:data
:feature:settings -.-> :core:database
:feature:settings -.-> :core:datastore
+ :feature:settings -.-> :core:domain
:feature:settings -.-> :core:model
:feature:settings -.-> :core:navigation
:feature:settings -.-> :core:nfc
From bb37c6635353e3da664a80bf30e4d64bb5f5adb2 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 2 Mar 2026 21:57:48 +0000
Subject: [PATCH 045/474] chore(deps): update co.touchlab:kermit to v2.1.0
(#4684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 48fe82c7c..2e7271980 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -222,7 +222,7 @@ 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" }
-kermit = { module = "co.touchlab:kermit", version = "2.0.8" }
+kermit = { module = "co.touchlab:kermit", version = "2.1.0" }
usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" }
vico-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico" }
From 0fc3fd280e4636841b85a0b6a270ec9846b55160 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 2 Mar 2026 18:34:13 -0600
Subject: [PATCH 046/474] chore(deps): update app.cash.turbine:turbine to
v1.2.1 (#4682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2e7271980..6db2d473a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,7 +26,7 @@ ktlint = "1.7.1"
kover = "0.9.7"
mockk = "1.14.9"
testRetry = "1.6.4"
-turbine = "1.1.0"
+turbine = "1.2.1"
# Compose Multiplatform
compose-multiplatform = "1.11.0-alpha03"
From 40244f8337afb916d4b65e2dfe63c764cc0f06aa Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 2 Mar 2026 18:34:50 -0600
Subject: [PATCH 047/474] chore: Scheduled updates (Firmware, Hardware,
Translations, Graphs) (#4686)
---
.../src/commonMain/composeResources/values-fi/strings.xml | 1 +
1 file changed, 1 insertion(+)
diff --git a/core/resources/src/commonMain/composeResources/values-fi/strings.xml b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
index 1883a6d50..ea2a0bed0 100644
--- a/core/resources/src/commonMain/composeResources/values-fi/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values-fi/strings.xml
@@ -311,6 +311,7 @@
Yksityisviesti
Tyhjennä NodeDB-tietokanta
Toimitus vahvistettu
+ Laitteesi saattaa katkaista yhteyden ja käynnistyä uudelleen, kun asetuksia otetaan käyttöön.
Virhe
Jätä huomiotta
Poista huomioimattomista
From 2c49db80418f9d557183e8d84b453afa50465561 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 3 Mar 2026 07:15:28 -0600
Subject: [PATCH 048/474] feat/decoupling (#4685)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.github/workflows/reusable-check.yml | 50 +-
.../filter/MessageFilterIntegrationTest.kt | 4 +-
.../com/geeksville/mesh/ApplicationModule.kt | 24 +-
.../com/geeksville/mesh/MeshServiceClient.kt | 4 +-
.../usecase/GetDiscoveredDevicesUseCase.kt | 6 +-
.../geeksville/mesh/model/DeviceListEntry.kt | 6 +-
.../com/geeksville/mesh/model/UIViewModel.kt | 26 +-
...ice.kt => AndroidRadioInterfaceService.kt} | 69 +--
.../mesh/repository/radio/InterfaceFactory.kt | 32 +-
.../mesh/repository/radio/InterfaceMapKey.kt | 8 +-
.../mesh/repository/radio/MockInterface.kt | 1 +
.../repository/radio/NordicBleInterface.kt | 13 +-
.../radio/NordicBleInterfaceSpec.kt | 13 +-
.../repository/radio/RadioRepositoryModule.kt | 4 +-
.../mesh/repository/radio/SerialInterface.kt | 1 +
.../mesh/repository/radio/StreamInterface.kt | 1 +
.../mesh/repository/radio/TCPInterface.kt | 1 +
.../mesh/service/AndroidAppWidgetUpdater.kt | 40 ++
...nager.kt => AndroidMeshLocationManager.kt} | 9 +-
.../mesh/service/AndroidMeshWorkerManager.kt | 42 ++
.../mesh/service/MarkAsReadReceiver.kt | 14 +-
.../mesh/service/MeshNodeManager.kt | 269 ----------
.../com/geeksville/mesh/service/MeshRouter.kt | 48 --
.../geeksville/mesh/service/MeshService.kt | 33 +-
.../service/MeshServiceNotificationsImpl.kt | 36 +-
.../mesh/service/ReactionReceiver.kt | 4 +-
.../geeksville/mesh/service/ReplyReceiver.kt | 45 +-
...viceBroadcasts.kt => ServiceBroadcasts.kt} | 81 ++-
.../main/java/com/geeksville/mesh/ui/Main.kt | 14 +-
.../ui/connections/ConnectionsViewModel.kt | 12 +-
.../mesh/ui/connections/ScannerViewModel.kt | 21 +-
.../components/CurrentlyConnectedInfo.kt | 2 +-
.../mesh/ui/sharing/ChannelViewModel.kt | 21 +-
.../mesh/widget/LocalStatsWidgetState.kt | 6 +-
.../mesh/widget/RefreshLocalStatsAction.kt | 8 +-
.../mesh/worker/ServiceKeepAliveWorker.kt | 4 +-
.../radio/NordicBleInterfaceRetryTest.kt | 1 +
.../radio/NordicBleInterfaceTest.kt | 3 +-
.../repository/radio/StreamInterfaceTest.kt | 1 +
.../java/com/geeksville/mesh/service/Fakes.kt | 12 +-
.../mesh/service/MeshMessageProcessorTest.kt | 122 -----
...dcastsTest.kt => ServiceBroadcastsTest.kt} | 15 +-
compose_compiler_config.conf | 4 +-
.../core/ble/BluetoothRepository.kt | 20 +-
core/data/build.gradle.kts | 1 +
.../core/data/di/RepositoryModule.kt | 151 ++++++
.../meshtastic/core/data/di/UseCaseModule.kt | 45 ++
.../core/data/manager/CommandSenderImpl.kt | 141 +++--
.../manager/FromRadioPacketHandlerImpl.kt | 56 +-
.../core/data/manager/HistoryManagerImpl.kt | 56 +-
.../data/manager/MeshActionHandlerImpl.kt | 115 +++--
.../data/manager/MeshConfigFlowManagerImpl.kt | 80 +--
.../data/manager/MeshConfigHandlerImpl.kt | 31 +-
.../data/manager/MeshConnectionManagerImpl.kt | 106 ++--
.../core/data/manager/MeshDataHandlerImpl.kt | 228 ++++-----
.../data/manager/MeshMessageProcessorImpl.kt | 98 ++--
.../core/data/manager/MeshRouterImpl.kt | 75 +++
.../core/data/manager/MessageFilterImpl.kt} | 25 +-
.../core/data/manager/MqttManagerImpl.kt | 18 +-
.../data/manager/NeighborInfoHandlerImpl.kt | 31 +-
.../core/data/manager/NodeManagerImpl.kt | 316 ++++++++++++
.../core/data/manager/PacketHandlerImpl.kt | 62 +--
.../data/manager/TracerouteHandlerImpl.kt | 41 +-
...ory.kt => DeviceHardwareRepositoryImpl.kt} | 11 +-
...odeRepository.kt => NodeRepositoryImpl.kt} | 125 +++--
.../core/data/repository/PacketRepository.kt | 361 -------------
.../data/repository/PacketRepositoryImpl.kt | 482 ++++++++++++++++++
...sitory.kt => RadioConfigRepositoryImpl.kt} | 28 +-
.../data/manager/CommandSenderHopLimitTest.kt | 31 +-
.../data/manager/CommandSenderImplTest.kt | 22 +-
.../manager/FromRadioPacketHandlerImplTest.kt | 23 +-
.../data/manager/HistoryManagerImplTest.kt | 12 +-
.../manager/MeshConnectionManagerImplTest.kt | 95 ++--
.../core/data/manager}/MeshDataHandlerTest.kt | 55 +-
.../data/manager/MessageFilterImplTest.kt} | 8 +-
.../core/data/manager/NodeManagerImplTest.kt | 29 +-
.../data/manager/PacketHandlerImplTest.kt | 32 +-
.../DeviceHardwareRepositoryTest.kt | 2 +-
.../data/repository/MeshLogRepositoryTest.kt | 2 +-
.../data/repository/NodeRepositoryTest.kt | 8 +-
core/database/build.gradle.kts | 1 +
.../core/database/dao/NodeInfoDaoTest.kt | 4 +-
.../core/database/DatabaseManager.kt | 19 +-
.../meshtastic/core/database/dao/PacketDao.kt | 14 +-
.../core/database/di/DatabaseModule.kt | 43 +-
.../core/database/entity/NodeEntity.kt | 4 +-
.../meshtastic/core/database/entity/Packet.kt | 32 +-
.../core/database/model/NodeTest.kt | 2 +-
core/domain/build.gradle.kts | 1 +
.../usecase/settings/AdminActionsUseCase.kt | 9 +-
.../settings/CleanNodeDatabaseUseCase.kt | 14 +-
.../usecase/settings/ExportDataUseCase.kt | 4 +-
.../usecase/settings/ExportProfileUseCase.kt | 2 +-
.../settings/ExportSecurityConfigUseCase.kt | 2 +-
.../usecase/settings/ImportProfileUseCase.kt | 2 +-
.../usecase/settings/InstallProfileUseCase.kt | 2 +-
.../usecase/settings/IsOtaCapableUseCase.kt | 8 +-
.../usecase/settings/MeshLocationUseCase.kt | 2 +-
.../settings/ProcessRadioResponseUseCase.kt | 4 +-
.../settings/SetAppIntroCompletedUseCase.kt | 6 +-
.../settings/SetDatabaseCacheLimitUseCase.kt | 4 +-
.../settings/SetMeshLogSettingsUseCase.kt | 2 +-
.../settings/SetProvideLocationUseCase.kt | 2 +-
.../usecase/settings/SetThemeUseCase.kt | 2 +-
.../settings/ToggleAnalyticsUseCase.kt | 2 +-
.../ToggleHomoglyphEncodingUseCase.kt | 2 +-
.../core/domain/FakeRadioController.kt | 20 +
.../domain/usecase/SendMessageUseCaseTest.kt | 24 +-
.../settings/AdminActionsUseCaseTest.kt | 2 +-
.../settings/CleanNodeDatabaseUseCaseTest.kt | 10 +-
.../usecase/settings/ExportDataUseCaseTest.kt | 5 +-
.../settings/IsOtaCapableUseCaseTest.kt | 6 +-
.../SetDatabaseCacheLimitUseCaseTest.kt | 2 +-
core/model/build.gradle.kts | 2 +
.../core/model/util/ChannelSetTest.kt | 2 +-
.../core/model/util/SharedContactTest.kt | 34 +-
.../core/model/util}/MeshDataMapperTest.kt | 38 +-
.../meshtastic/core/model/ChannelOption.kt | 6 +-
.../org/meshtastic/core/model/Contact.kt | 9 +
.../org/meshtastic/core/model}/InterfaceId.kt | 15 +-
.../org/meshtastic/core/model/MeshActivity.kt | 26 +
.../org/meshtastic/core}/model/Message.kt | 4 +-
.../kotlin/org/meshtastic/core}/model/Node.kt | 63 ++-
.../meshtastic/core}/model/NodeSortOption.kt | 2 +-
.../meshtastic/core/model/RadioController.kt | 251 ++++++++-
.../core/model}/RadioNotConnectedException.kt | 16 +-
.../org/meshtastic/core/model/Reaction.kt | 38 ++
.../kotlin/org/meshtastic/core}/model/TAK.kt | 2 +-
.../core/model}/service/ServiceAction.kt | 4 +-
.../core/model/service/TracerouteResponse.kt | 29 ++
.../meshtastic/core/model/util/ChannelSet.kt | 2 +-
core/network/build.gradle.kts | 6 +-
.../network/repository}/MQTTRepository.kt | 8 +-
.../repository}/TrustAllX509TrustManager.kt | 8 +-
core/prefs/build.gradle.kts | 1 +
.../meshtastic/core/prefs/di/PrefsModule.kt | 5 +
.../core/prefs/homoglyph/HomoglyphPrefs.kt | 5 +-
.../repository/build.gradle.kts | 26 +-
.../core/repository/AppWidgetUpdater.kt | 23 +
.../core/repository/CommandSender.kt | 89 ++++
.../core/repository/DatabaseManager.kt | 34 ++
.../repository/DeviceHardwareRepository.kt | 35 ++
.../core/repository/FromRadioPacketHandler.kt | 25 +
.../core/repository/HistoryManager.kt | 46 ++
.../core/repository/HomoglyphPrefs.kt | 21 +
.../core/repository/MeshActionHandler.kt | 123 +++++
.../core/repository/MeshConfigFlowManager.kt | 46 ++
.../core/repository/MeshConfigHandler.kt | 46 ++
.../core/repository/MeshConnectionManager.kt | 44 ++
.../core/repository/MeshDataHandler.kt | 47 ++
.../core/repository/MeshLocationManager.kt | 29 ++
.../core/repository/MeshMessageProcessor.kt | 35 ++
.../meshtastic/core/repository/MeshRouter.kt | 46 ++
.../repository}/MeshServiceNotifications.kt | 13 +-
.../core/repository/MeshWorkerManager.kt | 23 +
.../core/repository/MessageFilter.kt | 32 ++
.../core/repository}/MessageQueue.kt | 2 +-
.../meshtastic/core/repository/MqttManager.kt | 32 ++
.../core/repository/NeighborInfoHandler.kt | 23 +-
.../meshtastic/core/repository/NodeManager.kt | 104 ++++
.../core/repository/NodeRepository.kt | 177 +++++++
.../core/repository/PacketHandler.kt | 43 ++
.../core/repository/PacketRepository.kt | 213 ++++++++
.../core/repository/RadioConfigRepository.kt | 62 +++
.../core/repository/RadioInterfaceService.kt | 72 +++
.../core/repository/ServiceBroadcasts.kt | 39 ++
.../core/repository/ServiceRepository.kt | 147 ++++++
.../core/repository/TracerouteHandler.kt | 36 ++
.../repository}/usecase/SendMessageUseCase.kt | 63 ++-
.../service/AndroidRadioControllerImpl.kt | 54 +-
...ository.kt => AndroidServiceRepository.kt} | 58 +--
.../core/service/di/ServiceModule.kt | 9 +-
.../core/ui/component/ContactSharing.kt | 2 +-
.../core/ui/component/MainAppBar.kt | 2 +-
.../meshtastic/core/ui/component/NodeChip.kt | 2 +-
.../core/ui/component/SignalInfo.kt | 2 +-
.../preview/NodePreviewParameterProvider.kt | 2 +-
.../core/ui/component/preview/PreviewUtils.kt | 2 +-
.../core/ui/qr/ScannedQrCodeViewModel.kt | 20 +-
.../core/ui/share/SharedContactViewModel.kt | 8 +-
.../firmware/FirmwareUpdateViewModel.kt | 18 +-
.../feature/firmware/NordicDfuHandler.kt | 6 +-
.../feature/firmware/UsbUpdateHandler.kt | 14 +-
.../firmware/ota/Esp32OtaUpdateHandler.kt | 28 +-
.../firmware/ota/Esp32OtaUpdateHandlerTest.kt | 9 +-
.../org/meshtastic/feature/map/MapView.kt | 17 +-
.../meshtastic/feature/map/MapViewModel.kt | 14 +-
.../org/meshtastic/feature/map/MapView.kt | 4 +-
.../meshtastic/feature/map/MapViewModel.kt | 17 +-
.../feature/map/component/PulsingNodeChip.kt | 2 +-
.../feature/map/model/NodeClusterItem.kt | 2 +-
.../feature/map/BaseMapViewModel.kt | 152 +++---
.../feature/map/node/NodeMapViewModel.kt | 2 +-
.../feature/map/MapViewModelTest.kt | 14 +-
.../messaging/component/MessageItemTest.kt | 2 +-
.../meshtastic/feature/messaging/Message.kt | 4 +-
.../feature/messaging/MessageListPaged.kt | 9 +-
.../feature/messaging/MessageScreenEvent.kt | 5 +-
.../feature/messaging/MessageViewModel.kt | 20 +-
.../messaging/component/MessageItem.kt | 6 +-
.../feature/messaging/component/Reaction.kt | 17 +-
.../feature/messaging/di/MessagingModule.kt | 2 +-
.../domain/worker/SendMessageWorker.kt | 8 +-
.../domain/worker/WorkManagerMessageQueue.kt | 2 +-
.../feature/messaging/ui/contact/Contacts.kt | 2 +-
.../messaging/ui/contact/ContactsViewModel.kt | 74 +--
.../domain/worker/SendMessageWorkerTest.kt | 19 +-
feature/node/component/DeviceActions.kt | 2 +-
.../feature/node/component/InlineMap.kt | 5 +-
.../feature/node/component/InlineMap.kt | 2 +-
.../feature/node/compass/CompassViewModel.kt | 2 +-
.../node/component/AdministrationSection.kt | 4 +-
.../feature/node/component/DeviceActions.kt | 2 +-
.../node/component/EnvironmentMetrics.kt | 2 +-
.../node/component/LinkedCoordinatesItem.kt | 2 +-
.../node/component/NodeDetailsSection.kt | 2 +-
.../node/component/NodeFilterTextField.kt | 2 +-
.../feature/node/component/NodeItem.kt | 4 +-
.../feature/node/component/NodeMenuAction.kt | 2 +-
.../feature/node/component/NotesSection.kt | 2 +-
.../feature/node/component/PositionSection.kt | 2 +-
.../feature/node/component/PowerMetrics.kt | 2 +-
.../component/TelemetricActionsSection.kt | 2 +-
.../feature/node/detail/NodeDetailScreen.kt | 2 +-
.../node/detail/NodeDetailViewModel.kt | 6 +-
.../node/detail/NodeManagementActions.kt | 45 +-
.../feature/node/detail/NodeRequestActions.kt | 110 ++--
.../domain/usecase/GetFilteredNodesUseCase.kt | 6 +-
.../domain/usecase/GetNodeDetailsUseCase.kt | 10 +-
.../node/list/NodeFilterPreferences.kt | 5 +-
.../feature/node/list/NodeListScreen.kt | 2 +-
.../feature/node/list/NodeListViewModel.kt | 20 +-
.../feature/node/metrics/MetricsViewModel.kt | 6 +-
.../node/model/IsEffectivelyUnmessageable.kt | 4 +-
.../feature/node/model/MetricsState.kt | 2 +-
.../feature/node/model/NodeDetailAction.kt | 4 +-
.../node/detail/NodeManagementActionsTest.kt | 9 +-
.../usecase/GetFilteredNodesUseCaseTest.kt | 6 +-
.../feature/settings/AdministrationScreen.kt | 2 +-
.../feature/settings/SettingsViewModel.kt | 14 +-
.../settings/debugging/DebugViewModel.kt | 2 +-
.../filter/FilterSettingsViewModel.kt | 8 +-
.../settings/radio/CleanNodeDatabaseScreen.kt | 2 +-
.../radio/CleanNodeDatabaseViewModel.kt | 2 +-
.../settings/radio/RadioConfigViewModel.kt | 14 +-
.../component/ShutdownConfirmationDialog.kt | 2 +-
.../radio/component/TAKConfigItemList.kt | 4 +-
.../radio/component/UserConfigItemList.kt | 2 +-
.../feature/settings/SettingsViewModelTest.kt | 33 +-
.../settings/debugging/DebugViewModelTest.kt | 2 +-
.../filter/FilterSettingsViewModelTest.kt | 10 +-
.../radio/CleanNodeDatabaseViewModelTest.kt | 2 +-
.../radio/RadioConfigViewModelTest.kt | 10 +-
settings.gradle.kts | 1 +
254 files changed, 5132 insertions(+), 2666 deletions(-)
rename app/src/main/java/com/geeksville/mesh/repository/radio/{RadioInterfaceService.kt => AndroidRadioInterfaceService.kt} (87%)
create mode 100644 app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt
rename app/src/main/java/com/geeksville/mesh/service/{MeshLocationManager.kt => AndroidMeshLocationManager.kt} (93%)
create mode 100644 app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt
delete mode 100644 app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
delete mode 100644 app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt
rename app/src/main/java/com/geeksville/mesh/service/{MeshServiceBroadcasts.kt => ServiceBroadcasts.kt} (62%)
delete mode 100644 app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt
rename app/src/test/java/com/geeksville/mesh/service/{MeshServiceBroadcastsTest.kt => ServiceBroadcastsTest.kt} (82%)
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt
rename app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt (77%)
rename app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt (53%)
rename app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt (74%)
rename app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt (74%)
rename app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt (73%)
rename app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt (79%)
rename app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt (79%)
rename app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt (81%)
rename app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt (75%)
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt
rename core/{service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt => data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt} (69%)
rename app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt (84%)
rename app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt (77%)
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
rename app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt (77%)
rename app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt => core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt (74%)
rename core/data/src/main/kotlin/org/meshtastic/core/data/repository/{DeviceHardwareRepository.kt => DeviceHardwareRepositoryImpl.kt} (97%)
rename core/data/src/main/kotlin/org/meshtastic/core/data/repository/{NodeRepository.kt => NodeRepositoryImpl.kt} (67%)
delete mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt
create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
rename core/data/src/main/kotlin/org/meshtastic/core/data/repository/{RadioConfigRepository.kt => RadioConfigRepositoryImpl.kt} (80%)
rename app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/CommandSenderHopLimitTest.kt (78%)
rename app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt (76%)
rename app/src/test/java/com/geeksville/mesh/service/FromRadioPacketHandlerTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt (82%)
rename app/src/test/java/com/geeksville/mesh/service/StoreForwardHistoryRequestTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt (86%)
rename app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt (73%)
rename {app/src/test/java/com/geeksville/mesh/service => core/data/src/test/kotlin/org/meshtastic/core/data/manager}/MeshDataHandlerTest.kt (72%)
rename core/{service/src/test/kotlin/org/meshtastic/core/service/filter/MessageFilterServiceTest.kt => data/src/test/kotlin/org/meshtastic/core/data/manager/MessageFilterImplTest.kt} (94%)
rename app/src/test/java/com/geeksville/mesh/service/MeshNodeManagerTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt (80%)
rename app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt => core/data/src/test/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt (75%)
rename {app/src/test/java/com/geeksville/mesh/service => core/model/src/androidHostTest/kotlin/org/meshtastic/core/model/util}/MeshDataMapperTest.kt (70%)
rename {app/src/main/java/com/geeksville/mesh/repository/radio => core/model/src/commonMain/kotlin/org/meshtastic/core/model}/InterfaceId.kt (74%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshActivity.kt
rename core/{database/src/main/kotlin/org/meshtastic/core/database => model/src/commonMain/kotlin/org/meshtastic/core}/model/Message.kt (97%)
rename core/{database/src/main/kotlin/org/meshtastic/core/database => model/src/commonMain/kotlin/org/meshtastic/core}/model/Node.kt (87%)
rename core/{database/src/main/kotlin/org/meshtastic/core/database => model/src/commonMain/kotlin/org/meshtastic/core}/model/NodeSortOption.kt (97%)
rename {app/src/main/java/com/geeksville/mesh/service => core/model/src/commonMain/kotlin/org/meshtastic/core/model}/RadioNotConnectedException.kt (58%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt
rename core/{database/src/main/kotlin/org/meshtastic/core/database => model/src/commonMain/kotlin/org/meshtastic/core}/model/TAK.kt (98%)
rename core/{service/src/main/kotlin/org/meshtastic/core => model/src/commonMain/kotlin/org/meshtastic/core/model}/service/ServiceAction.kt (93%)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/TracerouteResponse.kt
rename {app/src/main/java/com/geeksville/mesh/repository/network => core/network/src/main/kotlin/org/meshtastic/core/network/repository}/MQTTRepository.kt (96%)
rename {app/src/main/java/com/geeksville/mesh/repository/network => core/network/src/main/kotlin/org/meshtastic/core/network/repository}/TrustAllX509TrustManager.kt (90%)
rename app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt => core/repository/build.gradle.kts (57%)
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppWidgetUpdater.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DatabaseManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceHardwareRepository.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HomoglyphPrefs.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLocationManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt
rename core/{service/src/main/kotlin/org/meshtastic/core/service => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/MeshServiceNotifications.kt (84%)
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshWorkerManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessageFilter.kt
rename core/{domain/src/main/kotlin/org/meshtastic/core/domain => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/MessageQueue.kt (96%)
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MqttManager.kt
rename app/src/main/java/com/geeksville/mesh/service/MeshDataMapper.kt => core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt (58%)
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioConfigRepository.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt
create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt
rename core/{domain/src/main/kotlin/org/meshtastic/core/domain => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/usecase/SendMessageUseCase.kt (73%)
rename core/service/src/main/kotlin/org/meshtastic/core/service/{ServiceRepository.kt => AndroidServiceRepository.kt} (68%)
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 85b9d46ba..e480374fd 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -89,30 +89,18 @@ jobs:
- name: Determine Tasks
id: tasks
run: |
- TASKS=""
- # Only run Lint and Unit Tests on the first API level and first flavor in the matrix to save time and resources
+ FLAVOR="${{ matrix.flavor }}"
+ FLAVOR_CAP=$(echo $FLAVOR | awk '{print toupper(substr($0,1,1))substr($0,2)}')
IS_FIRST_API=$(echo '${{ inputs.api_levels }}' | jq -r '.[0] == ${{ matrix.api_level }}')
IS_FIRST_FLAVOR=$(echo '${{ inputs.flavors }}' | jq -r '.[0] == "${{ matrix.flavor }}"')
- if [ "$IS_FIRST_API" = "true" ] && [ "$IS_FIRST_FLAVOR" = "true" ]; then
- [ "${{ inputs.run_lint }}" = "true" ] && TASKS="$TASKS spotlessCheck detekt "
- [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testDebugUnitTest "
- fi
-
- FLAVOR="${{ matrix.flavor }}"
- if [ "$IS_FIRST_API" = "true" ]; then
- if [ "$FLAVOR" = "google" ]; then
- TASKS="$TASKS assembleGoogleDebug "
- [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testGoogleDebugUnitTest "
- elif [ "$FLAVOR" = "fdroid" ]; then
- TASKS="$TASKS assembleFdroidDebug "
- [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testFdroidDebugUnitTest "
- fi
- fi
+ # Matrix-specific tasks
+ TASKS="assemble${FLAVOR_CAP}Debug "
+ [ "${{ inputs.run_lint }}" = "true" ] && TASKS="$TASKS lint${FLAVOR_CAP}Debug "
+ [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS test${FLAVOR_CAP}DebugUnitTest "
# Instrumented Test Tasks
if [ "${{ inputs.run_instrumented_tests }}" = "true" ]; then
- [ "$IS_FIRST_FLAVOR" = "true" ] && TASKS="$TASKS connectedDebugAndroidTest "
if [ "$FLAVOR" = "google" ]; then
TASKS="$TASKS connectedGoogleDebugAndroidTest "
elif [ "$FLAVOR" = "fdroid" ]; then
@@ -120,20 +108,22 @@ jobs:
fi
fi
- # Run coverage report if unit tests were executed
- if [ "${{ inputs.run_unit_tests }}" = "true" ] && [ "$IS_FIRST_API" = "true" ]; then
- if [ "$IS_FIRST_FLAVOR" = "true" ]; then
- TASKS="$TASKS koverXmlReportDebug "
- fi
- if [ "$FLAVOR" = "google" ]; then
- TASKS="$TASKS koverXmlReportGoogleDebug "
- elif [ "$FLAVOR" = "fdroid" ]; then
- TASKS="$TASKS koverXmlReportFdroidDebug "
- fi
+ # Run coverage report for this flavor
+ if [ "${{ inputs.run_unit_tests }}" = "true" ]; then
+ TASKS="$TASKS koverXmlReport${FLAVOR_CAP}Debug "
fi
echo "tasks=$TASKS" >> $GITHUB_OUTPUT
echo "is_first_api=$IS_FIRST_API" >> $GITHUB_OUTPUT
+ echo "is_first_flavor=$IS_FIRST_FLAVOR" >> $GITHUB_OUTPUT
+
+ - name: Code Style & Static Analysis
+ if: steps.tasks.outputs.is_first_api == 'true' && steps.tasks.outputs.is_first_flavor == 'true'
+ run: ./gradlew spotlessCheck detekt -Pci=true
+
+ - name: Shared Unit Tests
+ if: steps.tasks.outputs.is_first_api == 'true' && steps.tasks.outputs.is_first_flavor == 'true' && inputs.run_unit_tests == true
+ run: ./gradlew testDebugUnitTest koverXmlReportDebug -Pci=true --continue
- name: Enable KVM group perms
if: inputs.run_instrumented_tests == true
@@ -142,7 +132,7 @@ jobs:
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- - name: Run Check (with Emulator)
+ - name: Run Flavor Check (with Emulator)
if: inputs.run_instrumented_tests == true
uses: reactivecircus/android-emulator-runner@v2
env:
@@ -155,7 +145,7 @@ jobs:
disable-animations: true
script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true --continue --scan
- - name: Run Check (no Emulator)
+ - name: Run Flavor Check (no Emulator)
if: inputs.run_instrumented_tests == false
env:
VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
diff --git a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
index 6a701aa8c..2c327a7af 100644
--- a/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
+++ b/app/src/androidTest/java/com/geeksville/mesh/filter/MessageFilterIntegrationTest.kt
@@ -26,7 +26,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.prefs.filter.FilterPrefs
-import org.meshtastic.core.service.filter.MessageFilterService
+import org.meshtastic.core.repository.MessageFilter
import javax.inject.Inject
@HiltAndroidTest
@@ -37,7 +37,7 @@ class MessageFilterIntegrationTest {
@Inject lateinit var filterPrefs: FilterPrefs
- @Inject lateinit var filterService: MessageFilterService
+ @Inject lateinit var filterService: MessageFilter
@Before
fun setup() {
diff --git a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt b/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
index 5c546f476..dd07d74e2 100644
--- a/app/src/main/java/com/geeksville/mesh/ApplicationModule.kt
+++ b/app/src/main/java/com/geeksville/mesh/ApplicationModule.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,13 +14,17 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.geeksville.mesh
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
+import com.geeksville.mesh.repository.radio.AndroidRadioInterfaceService
+import com.geeksville.mesh.service.AndroidAppWidgetUpdater
+import com.geeksville.mesh.service.AndroidMeshLocationManager
+import com.geeksville.mesh.service.AndroidMeshWorkerManager
import com.geeksville.mesh.service.MeshServiceNotificationsImpl
+import com.geeksville.mesh.service.ServiceBroadcasts
import dagger.Binds
import dagger.Module
import dagger.Provides
@@ -28,7 +32,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.di.ProcessLifecycle
-import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.repository.MeshServiceNotifications
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@@ -37,6 +41,20 @@ interface ApplicationModule {
@Binds fun bindMeshServiceNotifications(impl: MeshServiceNotificationsImpl): MeshServiceNotifications
+ @Binds
+ fun bindMeshLocationManager(impl: AndroidMeshLocationManager): org.meshtastic.core.repository.MeshLocationManager
+
+ @Binds fun bindMeshWorkerManager(impl: AndroidMeshWorkerManager): org.meshtastic.core.repository.MeshWorkerManager
+
+ @Binds fun bindAppWidgetUpdater(impl: AndroidAppWidgetUpdater): org.meshtastic.core.repository.AppWidgetUpdater
+
+ @Binds
+ fun bindRadioInterfaceService(
+ impl: AndroidRadioInterfaceService,
+ ): org.meshtastic.core.repository.RadioInterfaceService
+
+ @Binds fun bindServiceBroadcasts(impl: ServiceBroadcasts): org.meshtastic.core.repository.ServiceBroadcasts
+
companion object {
@Provides @ProcessLifecycle
fun provideProcessLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get()
diff --git a/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt b/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt
index ca4b141a5..74fcea5bf 100644
--- a/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt
+++ b/app/src/main/java/com/geeksville/mesh/MeshServiceClient.kt
@@ -29,10 +29,10 @@ import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.SequentialJob
+import org.meshtastic.core.service.AndroidServiceRepository
import org.meshtastic.core.service.BindFailedException
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceClient
-import org.meshtastic.core.service.ServiceRepository
import javax.inject.Inject
/** A Activity-lifecycle-aware [ServiceClient] that binds [MeshService] once the Activity is started. */
@@ -41,7 +41,7 @@ class MeshServiceClient
@Inject
constructor(
@ActivityContext private val context: Context,
- private val serviceRepository: ServiceRepository,
+ private val serviceRepository: AndroidServiceRepository,
private val serviceSetupJob: SequentialJob,
) : ServiceClient(IMeshService.Stub::asInterface),
DefaultLifecycleObserver {
diff --git a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt b/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
index a6759dae6..4b7a25c50 100644
--- a/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
+++ b/app/src/main/java/com/geeksville/mesh/domain/usecase/GetDiscoveredDevicesUseCase.kt
@@ -22,18 +22,18 @@ import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.model.getMeshtasticShortName
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.usb.UsbRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.DatabaseManager
-import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.meshtastic
import java.util.Locale
diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt
index 6d2e4c448..d66d6fff0 100644
--- a/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/DeviceListEntry.kt
@@ -17,14 +17,14 @@
package com.geeksville.mesh.model
import android.hardware.usb.UsbManager
-import com.geeksville.mesh.repository.radio.InterfaceId
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.hoho.android.usbserial.driver.UsbSerialDriver
import no.nordicsemi.kotlin.ble.client.android.Peripheral
import no.nordicsemi.kotlin.ble.core.BondState
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
-import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.InterfaceId
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.anonymize
+import org.meshtastic.core.repository.RadioInterfaceService
/**
* A sealed class is used here to represent the different types of devices that can be displayed in the list. This is
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
index 52ef78ce5..a3511ca74 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIViewModel.kt
@@ -22,8 +22,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.repository.radio.MeshActivity
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
@@ -45,21 +43,24 @@ import org.jetbrains.compose.resources.getString
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
+import org.meshtastic.core.model.MeshActivity
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
+import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.model.util.dispatchMeshtasticUri
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.compromised_keys
+import org.meshtastic.core.service.AndroidServiceRepository
import org.meshtastic.core.service.IMeshService
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.ComposableContent
@@ -75,7 +76,8 @@ class UIViewModel
@Inject
constructor(
private val nodeDB: NodeRepository,
- private val serviceRepository: ServiceRepository,
+ private val serviceRepository: AndroidServiceRepository,
+ private val radioController: RadioController,
radioInterfaceService: RadioInterfaceService,
meshLogRepository: MeshLogRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
@@ -161,6 +163,10 @@ constructor(
val meshService: IMeshService?
get() = serviceRepository.meshService
+ fun setDeviceAddress(address: String) {
+ radioController.setDeviceAddress(address)
+ }
+
val unreadMessageCount =
packetRepository.getUnreadCountTotal().map { it.coerceAtLeast(0) }.stateInWhileSubscribed(initialValue = 0)
@@ -172,7 +178,7 @@ constructor(
}
// hardware info about our local device (can be null)
- val myNodeInfo: StateFlow
+ val myNodeInfo: StateFlow
get() = nodeDB.myNodeInfo
init {
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
similarity index 87%
rename from app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
rename to app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
index f7cf8fbd5..cd190ad45 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/AndroidRadioInterfaceService.kt
@@ -49,8 +49,11 @@ import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.InterfaceId
+import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.prefs.radio.RadioPrefs
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
@@ -65,9 +68,9 @@ import javax.inject.Singleton
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc... It is designed to be simple so it
* can be stubbed out with a simulated version as needed.
*/
-@Suppress("LongParameterList")
+@Suppress("LongParameterList", "TooManyFunctions")
@Singleton
-open class RadioInterfaceService
+class AndroidRadioInterfaceService
@Inject
constructor(
private val context: Application,
@@ -78,20 +81,20 @@ constructor(
private val radioPrefs: RadioPrefs,
private val interfaceFactory: InterfaceFactory,
private val analytics: PlatformAnalytics,
-) {
+) : RadioInterfaceService {
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
- val connectionState: StateFlow = _connectionState.asStateFlow()
+ override val connectionState: StateFlow = _connectionState.asStateFlow()
private val _receivedData = MutableSharedFlow(extraBufferCapacity = 64)
- val receivedData: SharedFlow = _receivedData
+ override val receivedData: SharedFlow = _receivedData
private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64)
val connectionError: SharedFlow = _connectionError.asSharedFlow()
// Thread-safe StateFlow for tracking device address changes
private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr)
- val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow()
+ override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow()
private val logSends = false
private val logReceives = false
@@ -100,8 +103,11 @@ constructor(
val mockInterfaceAddress: String by lazy { toInterfaceAddress(InterfaceId.MOCK, "") }
+ override val serviceScope: CoroutineScope
+ get() = _serviceScope
+
/** We recreate this scope each time we stop an interface */
- var serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
+ private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
private var radioIf: IRadioInterface = NopInterface("")
@@ -165,10 +171,10 @@ constructor(
}
/** Constructs a full radio address for the specific interface type. */
- fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
+ override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
interfaceFactory.toInterfaceAddress(interfaceId, rest)
- fun isMockInterface(): Boolean =
+ override fun isMockInterface(): Boolean =
BuildConfig.DEBUG || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
/**
@@ -185,7 +191,7 @@ constructor(
* where a is either x for bluetooth or s for serial and t is an interface specific address (macaddr or a device
* path)
*/
- fun getDeviceAddress(): String? {
+ override fun getDeviceAddress(): String? {
// If the user has unpaired our device, treat things as if we don't have one
var address = radioPrefs.devAddr
@@ -228,10 +234,11 @@ constructor(
}
// Handle an incoming packet from the radio, broadcasts it as an android intent
- open fun handleFromRadio(p: ByteArray) {
+ @Suppress("TooGenericExceptionCaught")
+ override fun handleFromRadio(bytes: ByteArray) {
if (logReceives) {
try {
- receivedPacketsLog.write(p)
+ receivedPacketsLog.write(bytes)
receivedPacketsLog.flush()
} catch (t: Throwable) {
Logger.w(t) { "Failed to write receive log in handleFromRadio" }
@@ -239,29 +246,33 @@ constructor(
}
try {
- processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(p) }
+ processLifecycle.coroutineScope.launch(dispatchers.io) { _receivedData.emit(bytes) }
emitReceiveActivity()
} catch (t: Throwable) {
Logger.e(t) { "RadioInterfaceService.handleFromRadio failed while emitting data" }
}
}
- fun onConnect() {
+ override fun onConnect() {
if (_connectionState.value != ConnectionState.Connected) {
broadcastConnectionChanged(ConnectionState.Connected)
}
}
- fun onDisconnect(isPermanent: Boolean) {
+ override fun onDisconnect(isPermanent: Boolean) {
val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep
if (_connectionState.value != newTargetState) {
broadcastConnectionChanged(newTargetState)
}
}
- fun onDisconnect(error: BleError) {
- processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(error) }
- onDisconnect(!error.shouldReconnect)
+ override fun onDisconnect(error: Any) {
+ if (error is BleError) {
+ processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(error) }
+ onDisconnect(!error.shouldReconnect)
+ } else {
+ onDisconnect(isPermanent = true)
+ }
}
/** Start our configured interface (if it isn't already running) */
@@ -311,8 +322,8 @@ constructor(
r.close()
// cancel any old jobs and get ready for the new ones
- serviceScope.cancel("stopping interface")
- serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
+ _serviceScope.cancel("stopping interface")
+ _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
if (logSends) {
sentPacketsLog.close()
@@ -356,26 +367,28 @@ constructor(
true
}
- fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions { setBondedDeviceAddress(deviceAddr) }
+ override fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions {
+ setBondedDeviceAddress(deviceAddr)
+ }
/**
* If the service is not currently connected to the radio, try to connect now. At boot the radio interface service
* will not connect to a radio until this call is received.
*/
- fun connect() = toRemoteExceptions {
+ override fun connect() = toRemoteExceptions {
// We don't start actually talking to our device until MeshService binds to us - this prevents
// broadcasting connection events before MeshService is ready to receive them
startInterface()
initStateListeners()
}
- fun sendToRadio(a: ByteArray) {
+ override fun sendToRadio(bytes: ByteArray) {
// Do this in the IO thread because it might take a while (and we don't care about the result code)
- serviceScope.handledLaunch { handleSendToRadio(a) }
+ _serviceScope.handledLaunch { handleSendToRadio(bytes) }
}
private val _meshActivity = MutableSharedFlow(extraBufferCapacity = 64)
- val meshActivity: SharedFlow = _meshActivity.asSharedFlow()
+ override val meshActivity: SharedFlow = _meshActivity.asSharedFlow()
private fun emitSendActivity() {
// Use tryEmit for SharedFlow as it's non-blocking
@@ -392,9 +405,3 @@ constructor(
}
}
}
-
-sealed class MeshActivity {
- data object Send : MeshActivity()
-
- data object Receive : MeshActivity()
-}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt
index ffb34c2a8..f511cb555 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceFactory.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,41 +14,37 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.geeksville.mesh.repository.radio
+import org.meshtastic.core.model.InterfaceId
import javax.inject.Inject
import javax.inject.Provider
/**
* Entry point for create radio backend instances given a specific address.
*
- * This class is responsible for building and dissecting radio addresses based upon
- * their interface type and the "rest" of the address (which varies per implementation).
+ * This class is responsible for building and dissecting radio addresses based upon their interface type and the "rest"
+ * of the address (which varies per implementation).
*/
-class InterfaceFactory @Inject constructor(
+class InterfaceFactory
+@Inject
+constructor(
private val nopInterfaceFactory: NopInterfaceFactory,
- private val specMap: Map>>
+ private val specMap: Map>>,
) {
- internal val nopInterface by lazy {
- nopInterfaceFactory.create("")
- }
+ internal val nopInterface by lazy { nopInterfaceFactory.create("") }
- fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String {
- return "${interfaceId.id}$rest"
- }
+ fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
fun createInterface(address: String): IRadioInterface {
val (spec, rest) = splitAddress(address)
return spec?.createInterface(rest) ?: nopInterface
}
- fun addressValid(address: String?): Boolean {
- return address?.let {
- val (spec, rest) = splitAddress(it)
- spec?.addressValid(rest)
- } ?: false
- }
+ fun addressValid(address: String?): Boolean = address?.let {
+ val (spec, rest) = splitAddress(it)
+ spec?.addressValid(rest)
+ } ?: false
private fun splitAddress(address: String): Pair?, String> {
val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it]?.get() }
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt
index d6d6ae2ea..fc9170c6a 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/InterfaceMapKey.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,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package com.geeksville.mesh.repository.radio
import dagger.MapKey
+import org.meshtastic.core.model.InterfaceId
-/**
- * Dagger `MapKey` implementation allowing for `InterfaceId` to be used as a map key.
- */
+/** Dagger `MapKey` implementation allowing for `InterfaceId` to be used as a map key. */
@MapKey
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_SETTER, AnnotationTarget.PROPERTY_GETTER)
@Retention(AnnotationRetention.RUNTIME)
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt
index 5b67d694f..2dc509ed2 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt
@@ -27,6 +27,7 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.getInitials
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
index 19e047139..aa72dfdd4 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
@@ -18,7 +18,6 @@ package com.geeksville.mesh.repository.radio
import android.annotation.SuppressLint
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.service.RadioNotConnectedException
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CompletableDeferred
@@ -58,6 +57,8 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
import org.meshtastic.core.ble.retryBleOperation
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.model.RadioNotConnectedException
+import org.meshtastic.core.repository.RadioInterfaceService
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
@@ -95,7 +96,7 @@ constructor(
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
}
}
- service.onDisconnect(BleError.from(throwable))
+ service.onDisconnect(error = BleError.from(throwable))
}
private val connectionScope: CoroutineScope =
@@ -152,7 +153,7 @@ constructor(
"Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
}
try {
- service.handleFromRadio(p = packet)
+ service.handleFromRadio(packet)
} catch (t: Throwable) {
Logger.e(t) { "[$address] Failed to execute service.handleFromRadio()" }
}
@@ -256,7 +257,7 @@ constructor(
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
- service.onDisconnect(BleError.Disconnected(reason = state.reason))
+ service.onDisconnect(error = BleError.Disconnected(reason = state.reason))
}
private suspend fun discoverServicesAndSetupCharacteristics() {
@@ -286,12 +287,12 @@ constructor(
service.onConnect()
} else {
Logger.w { "[$address] Discovery failed: missing required characteristics" }
- service.onDisconnect(BleError.DiscoveryFailed("One or more characteristics not found"))
+ service.onDisconnect(error = BleError.DiscoveryFailed("One or more characteristics not found"))
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Service discovery failed" }
bleConnection.disconnect()
- service.onDisconnect(BleError.from(e))
+ service.onDisconnect(error = BleError.from(e))
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt
index 49f989452..112d38e29 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceSpec.kt
@@ -31,13 +31,10 @@ constructor(
override fun createInterface(rest: String): NordicBleInterface = factory.create(rest)
/** Return true if this address is still acceptable. For BLE that means, still bonded */
- override fun addressValid(rest: String): Boolean {
- val allPaired = bluetoothRepository.state.value.bondedDevices.map { it.address }.toSet()
- return if (!allPaired.contains(rest)) {
- Logger.w { "Ignoring stale bond to ${rest.anonymize}" }
- false
- } else {
- true
- }
+ override fun addressValid(rest: String): Boolean = if (!bluetoothRepository.isBonded(rest)) {
+ Logger.w { "Ignoring stale bond to ${rest.anonymize}" }
+ false
+ } else {
+ true
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt
index 6a1d91f1a..88d957917 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioRepositoryModule.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 com.geeksville.mesh.repository.radio
import dagger.Binds
@@ -23,6 +22,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoMap
import dagger.multibindings.Multibinds
+import org.meshtastic.core.model.InterfaceId
@Suppress("unused") // Used by hilt
@Module
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt
index 4ebaf85d5..04d67b879 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt
@@ -23,6 +23,7 @@ import com.geeksville.mesh.repository.usb.UsbRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.repository.RadioInterfaceService
import java.util.concurrent.atomic.AtomicReference
/** An interface that assumes we are talking to a meshtastic device via USB serial */
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt
index 538f4088a..973c38838 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/StreamInterface.kt
@@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import org.meshtastic.core.repository.RadioInterfaceService
/**
* An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
index e2eeefa4c..a6a8320a5 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt
@@ -26,6 +26,7 @@ import org.meshtastic.core.common.util.Exceptions
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import java.io.BufferedInputStream
diff --git a/app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt b/app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt
new file mode 100644
index 000000000..9735b0ab5
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/AndroidAppWidgetUpdater.kt
@@ -0,0 +1,40 @@
+/*
+ * 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 com.geeksville.mesh.service
+
+import android.content.Context
+import androidx.glance.appwidget.updateAll
+import com.geeksville.mesh.widget.LocalStatsWidget
+import dagger.hilt.android.qualifiers.ApplicationContext
+import org.meshtastic.core.repository.AppWidgetUpdater
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AndroidAppWidgetUpdater @Inject constructor(@ApplicationContext private val context: Context) : AppWidgetUpdater {
+ override suspend fun updateAll() {
+ // Kickstart the widget composition.
+ // The widget internally uses collectAsState() and its own sampled StateFlow
+ // to drive updates automatically without excessive IPC and recreation.
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ LocalStatsWidget().updateAll(context)
+ } catch (e: Exception) {
+ co.touchlab.kermit.Logger.e(e) { "Failed to update widgets" }
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshLocationManager.kt
similarity index 93%
rename from app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt
rename to app/src/main/java/com/geeksville/mesh/service/AndroidMeshLocationManager.kt
index 482424a5e..7ab35c151 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshLocationManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshLocationManager.kt
@@ -29,23 +29,24 @@ import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.data.repository.LocationRepository
import org.meshtastic.core.model.Position
+import org.meshtastic.core.repository.MeshLocationManager
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import org.meshtastic.proto.Position as ProtoPosition
@Singleton
-class MeshLocationManager
+class AndroidMeshLocationManager
@Inject
constructor(
private val context: Application,
private val locationRepository: LocationRepository,
-) {
+) : MeshLocationManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var locationFlow: Job? = null
@SuppressLint("MissingPermission")
- fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) {
+ override fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) {
this.scope = scope
if (locationFlow?.isActive == true) return
@@ -76,7 +77,7 @@ constructor(
}
}
- fun stop() {
+ override fun stop() {
if (locationFlow?.isActive == true) {
Logger.i { "Stopping location requests" }
locationFlow?.cancel()
diff --git a/app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt
new file mode 100644
index 000000000..8b235ea5c
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/service/AndroidMeshWorkerManager.kt
@@ -0,0 +1,42 @@
+/*
+ * 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 com.geeksville.mesh.service
+
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import org.meshtastic.core.repository.MeshWorkerManager
+import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AndroidMeshWorkerManager @Inject constructor(private val workManager: WorkManager) : MeshWorkerManager {
+ override fun enqueueSendMessage(packetId: Int) {
+ val workRequest =
+ OneTimeWorkRequestBuilder()
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .build()
+
+ workManager.enqueueUniqueWork(
+ "${SendMessageWorker.WORK_NAME_PREFIX}$packetId",
+ ExistingWorkPolicy.REPLACE,
+ workRequest,
+ )
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt
index 3f1a85ec3..23f6b1737 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt
@@ -25,32 +25,34 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.PacketRepository
import javax.inject.Inject
/** A [BroadcastReceiver] that handles "Mark as read" actions from notifications. */
@AndroidEntryPoint
class MarkAsReadReceiver : BroadcastReceiver() {
+
@Inject lateinit var packetRepository: PacketRepository
- @Inject lateinit var meshServiceNotifications: MeshServiceNotifications
+ @Inject lateinit var serviceNotifications: MeshServiceNotifications
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
companion object {
- const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ_ACTION"
- const val CONTACT_KEY = "contactKey"
+ const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ"
+ const val CONTACT_KEY = "contact_key"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == MARK_AS_READ_ACTION) {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: return
val pendingResult = goAsync()
+
scope.launch {
try {
packetRepository.clearUnreadCount(contactKey, nowMillis)
- meshServiceNotifications.cancelMessageNotification(contactKey)
+ serviceNotifications.cancelMessageNotification(contactKey)
} finally {
pendingResult.finish()
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
deleted file mode 100644
index 1f284c7a7..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
+++ /dev/null
@@ -1,269 +0,0 @@
-/*
- * 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 com.geeksville.mesh.service
-
-import androidx.annotation.VisibleForTesting
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.first
-import okio.ByteString
-import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.database.entity.MetadataEntity
-import org.meshtastic.core.database.entity.NodeEntity
-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
-import org.meshtastic.proto.Paxcount
-import org.meshtastic.proto.StatusMessage
-import org.meshtastic.proto.Telemetry
-import org.meshtastic.proto.User
-import java.util.concurrent.ConcurrentHashMap
-import javax.inject.Inject
-import javax.inject.Singleton
-import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
-import org.meshtastic.proto.Position as ProtoPosition
-
-@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
-@Singleton
-class MeshNodeManager
-@Inject
-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()
- val nodeDBbyID = ConcurrentHashMap()
-
- fun start(scope: CoroutineScope) {
- this.scope = scope
- }
-
- val isNodeDbReady = MutableStateFlow(false)
- val allowNodeDbWrites = MutableStateFlow(false)
-
- var myNodeNum: Int? = null
-
- companion object {
- private const val TIME_MS_TO_S = 1000L
- }
-
- @VisibleForTesting internal constructor() : this(null, null, null)
-
- fun loadCachedNodeDB() {
- scope.handledLaunch {
- val nodes = nodeRepository?.getNodeEntityDBbyNumFlow()?.first() ?: emptyMap()
- nodeDBbyNodeNum.putAll(nodes)
- nodes.values.forEach { nodeDBbyID[it.user.id] = it }
- myNodeNum = nodeRepository?.myNodeInfo?.value?.myNodeNum
- }
- }
-
- fun clear() {
- nodeDBbyNodeNum.clear()
- nodeDBbyID.clear()
- isNodeDbReady.value = false
- allowNodeDbWrites.value = false
- myNodeNum = null
- }
-
- fun getMyNodeInfo(): MyNodeInfo? {
- val mi = nodeRepository?.myNodeInfo?.value ?: return null
- val myNode = nodeDBbyNodeNum[mi.myNodeNum]
- return MyNodeInfo(
- myNodeNum = mi.myNodeNum,
- hasGPS = (myNode?.position?.latitude_i ?: 0) != 0,
- model = mi.model ?: myNode?.user?.hw_model?.name,
- firmwareVersion = mi.firmwareVersion,
- couldUpdate = mi.couldUpdate,
- shouldUpdate = mi.shouldUpdate,
- currentPacketId = mi.currentPacketId,
- messageTimeoutMsec = mi.messageTimeoutMsec,
- minAppVersion = mi.minAppVersion,
- maxChannels = mi.maxChannels,
- hasWifi = mi.hasWifi,
- channelUtilization = 0f,
- airUtilTx = 0f,
- deviceId = mi.deviceId ?: myNode?.user?.id,
- )
- }
-
- fun getMyId(): String {
- val num = myNodeNum ?: nodeRepository?.myNodeInfo?.value?.myNodeNum ?: return ""
- return nodeDBbyNodeNum[num]?.user?.id ?: ""
- }
-
- fun getNodes(): List = nodeDBbyNodeNum.values.map { it.toNodeInfo() }
-
- fun removeByNodenum(nodeNum: Int) {
- nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) }
- }
-
- fun getOrCreateNodeInfo(n: Int, channel: Int = 0): NodeEntity = nodeDBbyNodeNum.getOrPut(n) {
- val userId = DataPacket.nodeNumToDefaultId(n)
- val defaultUser =
- User(
- id = userId,
- long_name = "Meshtastic ${userId.takeLast(n = 4)}",
- short_name = userId.takeLast(n = 4),
- hw_model = HardwareModel.UNSET,
- )
-
- NodeEntity(
- num = n,
- user = defaultUser,
- longName = defaultUser.long_name,
- shortName = defaultUser.short_name,
- channel = channel,
- )
- }
-
- fun updateNodeInfo(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, updateFn: (NodeEntity) -> Unit) {
- val info = getOrCreateNodeInfo(nodeNum, channel)
- updateFn(info)
- if (info.user.id.isNotEmpty()) {
- nodeDBbyID[info.user.id] = info
- }
-
- if (info.user.id.isNotEmpty() && isNodeDbReady.value) {
- scope.handledLaunch { nodeRepository?.upsert(info) }
- }
-
- if (withBroadcast) {
- serviceBroadcasts?.broadcastNodeChange(info.toNodeInfo())
- }
- }
-
- fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {
- scope.handledLaunch { nodeRepository?.insertMetadata(MetadataEntity(nodeNum, metadata)) }
- }
-
- fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) {
- updateNodeInfo(fromNum) {
- val newNode = (it.isUnknownUser && p.hw_model != HardwareModel.UNSET)
- val shouldPreserve = shouldPreserveExistingUser(it.user, p)
-
- if (shouldPreserve) {
- it.longName = it.user.long_name
- it.shortName = it.user.short_name
- it.channel = channel
- it.manuallyVerified = manuallyVerified
- } else {
- val keyMatch = !it.hasPKC || it.user.public_key == p.public_key
- it.user = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY)
- it.longName = p.long_name
- it.shortName = p.short_name
- it.channel = channel
- it.manuallyVerified = manuallyVerified
- if (newNode) {
- serviceNotifications?.showNewNodeSeenNotification(it)
- }
- }
- }
- }
-
- fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long = nowMillis) {
- if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) {
- Logger.d { "Ignoring nop position update for the local node" }
- } else {
- updateNodeInfo(fromNum) { it.setPosition(p, (defaultTime / TIME_MS_TO_S).toInt()) }
- }
- }
-
- fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) {
- updateNodeInfo(fromNum) { nodeEntity ->
- when {
- telemetry.device_metrics != null -> nodeEntity.deviceTelemetry = telemetry
- telemetry.environment_metrics != null -> nodeEntity.environmentTelemetry = telemetry
- telemetry.power_metrics != null -> nodeEntity.powerTelemetry = telemetry
- }
- }
- }
-
- fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) {
- updateNodeInfo(fromNum) { it.paxcounter = p }
- }
-
- fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) {
- updateNodeStatus(fromNum, s.status)
- }
-
- fun updateNodeStatus(nodeNum: Int, status: String?) {
- updateNodeInfo(nodeNum) { it.nodeStatus = status?.takeIf { s -> s.isNotEmpty() } }
- }
-
- fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) {
- updateNodeInfo(info.num, withBroadcast = withBroadcast) { entity ->
- val user = info.user
- if (user != null) {
- if (shouldPreserveExistingUser(entity.user, user)) {
- entity.longName = entity.user.long_name
- entity.shortName = entity.user.short_name
- } else {
- var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it }
- if (info.via_mqtt) {
- newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
- }
- entity.user = newUser
- entity.longName = newUser.long_name
- entity.shortName = newUser.short_name
- }
- }
- val position = info.position
- if (position != null) {
- entity.position = position
- entity.latitude = Position.degD(position.latitude_i ?: 0)
- entity.longitude = Position.degD(position.longitude_i ?: 0)
- }
- entity.lastHeard = info.last_heard
- if (info.device_metrics != null) {
- entity.deviceTelemetry = Telemetry(device_metrics = info.device_metrics)
- }
- entity.channel = info.channel
- entity.viaMqtt = info.via_mqtt
- entity.hopsAway = info.hops_away ?: -1
- entity.isFavorite = info.is_favorite
- entity.isIgnored = info.is_ignored
- entity.isMuted = info.is_muted
- }
- }
-
- private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean {
- val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
- val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET
- val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET
- return hasExistingUser && isDefaultName && isDefaultHwModel
- }
-
- override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
- DataPacket.ID_BROADCAST
- } else {
- nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt
deleted file mode 100644
index b61bb6e02..000000000
--- a/app/src/main/java/com/geeksville/mesh/service/MeshRouter.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * 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 com.geeksville.mesh.service
-
-import kotlinx.coroutines.CoroutineScope
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Orchestrates the specialized packet handlers for the [MeshService]. This class serves as a central registry and
- * lifecycle manager for all routing sub-components.
- */
-@Suppress("LongParameterList")
-@Singleton
-class MeshRouter
-@Inject
-constructor(
- val dataHandler: MeshDataHandler,
- val configHandler: MeshConfigHandler,
- val tracerouteHandler: MeshTracerouteHandler,
- val neighborInfoHandler: MeshNeighborInfoHandler,
- val configFlowManager: MeshConfigFlowManager,
- val mqttManager: MeshMqttManager,
- val actionHandler: MeshActionHandler,
-) {
- fun start(scope: CoroutineScope) {
- dataHandler.start(scope)
- configHandler.start(scope)
- tracerouteHandler.start(scope)
- neighborInfoHandler.start(scope)
- configFlowManager.start(scope)
- actionHandler.start(scope)
- }
-}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index 2f01f3368..cf97cd5c2 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -25,7 +25,6 @@ import android.os.IBinder
import androidx.core.app.ServiceCompat
import co.touchlab.kermit.Logger
import com.geeksville.mesh.BuildConfig
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
@@ -36,17 +35,27 @@ import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.toRemoteExceptions
-import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.RadioNotConnectedException
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.MeshLocationManager
+import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshRouter
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.service.IMeshService
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.SERVICE_NOTIFY_ID
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.PortNum
import javax.inject.Inject
@@ -58,17 +67,15 @@ class MeshService : Service() {
@Inject lateinit var serviceRepository: ServiceRepository
- @Inject lateinit var connectionStateHolder: ConnectionStateHandler
-
@Inject lateinit var packetHandler: PacketHandler
- @Inject lateinit var serviceBroadcasts: MeshServiceBroadcasts
+ @Inject lateinit var serviceBroadcasts: ServiceBroadcasts
- @Inject lateinit var nodeManager: MeshNodeManager
+ @Inject lateinit var nodeManager: NodeManager
@Inject lateinit var messageProcessor: MeshMessageProcessor
- @Inject lateinit var commandSender: MeshCommandSender
+ @Inject lateinit var commandSender: CommandSender
@Inject lateinit var locationManager: MeshLocationManager
@@ -90,7 +97,7 @@ class MeshService : Service() {
fun actionReceived(portNum: Int): String {
val portType = PortNum.fromValue(portNum)
val portStr = portType?.toString() ?: portNum.toString()
- return com.geeksville.mesh.service.actionReceived(portStr)
+ return actionReceived(portStr)
}
fun createIntent(context: Context) = Intent(context, MeshService::class.java)
@@ -143,7 +150,7 @@ class MeshService : Service() {
val a = radioInterfaceService.getDeviceAddress()
val wantForeground = a != null && a != NO_DEVICE_SELECTED
- val notification = connectionManager.updateStatusNotification()
+ val notification = connectionManager.updateStatusNotification() as android.app.Notification
val foregroundServiceType =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -311,7 +318,7 @@ class MeshService : Service() {
override fun getNodes(): List = nodeManager.getNodes()
- override fun connectionState(): String = connectionStateHolder.connectionState.value.toString()
+ override fun connectionState(): String = serviceRepository.connectionState.value.toString()
override fun startProvideLocation() {
locationManager.start(serviceScope) { commandSender.sendPosition(it) }
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 babdc5565..47b0a7fb2 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
@@ -47,13 +47,15 @@ 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
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.database.entity.NodeEntity
-import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Message
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.getString
@@ -86,8 +88,6 @@ import org.meshtastic.core.resources.no_local_stats
import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.you
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.SERVICE_NOTIFY_ID
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
@@ -309,16 +309,14 @@ constructor(
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.getNodeEntityDBbyNumFlow().first() }
- nodes[myNodeNum]?.let { entity ->
+ val nodes = runBlocking { repo.nodeDBbyNum.first() }
+ nodes[myNodeNum]?.let { node ->
if (cachedDeviceMetrics == null) {
- cachedDeviceMetrics = entity.deviceTelemetry.device_metrics
+ cachedDeviceMetrics = node.deviceMetrics
}
if (cachedLocalStats == null) {
// Fallback to DB stats if repository hasn't received any fresh ones yet
- cachedLocalStats =
- repo.localStats.value.takeIf { it.uptime_seconds != 0 }
- ?: entity.deviceTelemetry.local_stats
+ cachedLocalStats = repo.localStats.value.takeIf { it.uptime_seconds != 0 }
}
}
}
@@ -477,12 +475,12 @@ constructor(
notificationManager.notify(name.hashCode(), notification)
}
- override fun showNewNodeSeenNotification(node: NodeEntity) {
+ override fun showNewNodeSeenNotification(node: Node) {
val notification = createNewNodeSeenNotification(node.user.short_name, node.user.long_name, node.num)
notificationManager.notify(node.num, notification)
}
- override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {
+ override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {
val notification = createLowBatteryNotification(node, isRemote)
notificationManager.notify(node.num, notification)
}
@@ -495,7 +493,7 @@ constructor(
override fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode())
- override fun cancelLowBatteryNotification(node: NodeEntity) = notificationManager.cancel(node.num)
+ override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
override fun clearClientNotification(notification: ClientNotification) =
notificationManager.cancel(notification.toString().hashCode())
@@ -673,11 +671,11 @@ constructor(
return builder.build()
}
- private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification {
+ private fun createLowBatteryNotification(node: Node, 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.deviceMetrics?.battery_level ?: 0
- val message = getString(Res.string.low_battery_message).format(node.longName, batteryLevel)
+ val title = getString(Res.string.low_battery_title).format(node.user.short_name)
+ val batteryLevel = node.deviceMetrics.battery_level ?: 0
+ val message = getString(Res.string.low_battery_message).format(node.user.long_name, batteryLevel)
return commonBuilder(type, createOpenNodeDetailIntent(node.num))
.setCategory(Notification.CATEGORY_STATUS)
diff --git a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt
index 8462d8ec9..bea76c147 100644
--- a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt
@@ -25,8 +25,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
-import org.meshtastic.core.service.ServiceAction
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.model.service.ServiceAction
+import org.meshtastic.core.repository.ServiceRepository
import javax.inject.Inject
@AndroidEntryPoint
diff --git a/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt
index a80839176..e21039670 100644
--- a/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt
@@ -17,12 +17,19 @@
package com.geeksville.mesh.service
import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
import androidx.core.app.RemoteInput
import dagger.hilt.android.AndroidEntryPoint
import jakarta.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.ServiceRepository
/**
* A [BroadcastReceiver] that handles inline replies from notifications.
@@ -33,32 +40,42 @@ import org.meshtastic.core.service.ServiceRepository
*/
@AndroidEntryPoint
class ReplyReceiver : BroadcastReceiver() {
- @Inject lateinit var serviceRepository: ServiceRepository
+ @Inject lateinit var radioController: RadioController
@Inject lateinit var meshServiceNotifications: MeshServiceNotifications
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
companion object {
const val REPLY_ACTION = "com.geeksville.mesh.REPLY_ACTION"
const val CONTACT_KEY = "contactKey"
const val KEY_TEXT_REPLY = "key_text_reply"
}
- private fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}") {
- // contactKey: unique contact key filter (channel)+(nodeId)
- val channel = contactKey[0].digitToIntOrNull()
- val dest = if (channel != null) contactKey.substring(1) else contactKey
- val p = DataPacket(dest, channel ?: 0, str)
- serviceRepository.meshService?.send(p)
- }
-
- override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
+ override fun onReceive(context: Context, intent: Intent) {
val remoteInput = RemoteInput.getResultsFromIntent(intent)
if (remoteInput != null) {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: ""
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() ?: ""
- sendMessage(message, contactKey)
- meshServiceNotifications.cancelMessageNotification(contactKey)
+
+ val pendingResult = goAsync()
+ scope.launch {
+ try {
+ sendMessage(message, contactKey)
+ meshServiceNotifications.cancelMessageNotification(contactKey)
+ } finally {
+ pendingResult.finish()
+ }
+ }
}
}
+
+ private suspend fun sendMessage(str: String, contactKey: String) {
+ // contactKey: unique contact key filter (channel)+(nodeId)
+ val channel = contactKey.getOrNull(0)?.digitToIntOrNull()
+ val dest = if (channel != null) contactKey.substring(1) else contactKey
+ val p = DataPacket(dest, channel ?: 0, str)
+ radioController.sendMessage(p)
+ }
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/ServiceBroadcasts.kt
similarity index 62%
rename from app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
rename to app/src/main/java/com/geeksville/mesh/service/ServiceBroadcasts.kt
index 34ce09dec..99d0bc724 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/ServiceBroadcasts.kt
@@ -24,57 +24,99 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.util.toPIIString
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.ServiceRepository
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
+import org.meshtastic.core.repository.ServiceBroadcasts as SharedServiceBroadcasts
@Singleton
-class MeshServiceBroadcasts
+class ServiceBroadcasts
@Inject
constructor(
@ApplicationContext private val context: Context,
- private val connectionStateHolder: ConnectionStateHandler,
private val serviceRepository: ServiceRepository,
-) {
+) : SharedServiceBroadcasts {
// A mapping of receiver class name to package name - used for explicit broadcasts
private val clientPackages = mutableMapOf()
- fun subscribeReceiver(receiverName: String, packageName: String) {
+ override fun subscribeReceiver(receiverName: String, packageName: String) {
clientPackages[receiverName] = packageName
}
/** Broadcast some received data Payload will be a DataPacket */
- fun broadcastReceivedData(payload: DataPacket) {
- val action = MeshService.actionReceived(payload.dataType)
- explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, payload))
+ override fun broadcastReceivedData(dataPacket: DataPacket) {
+ val action = MeshService.actionReceived(dataPacket.dataType)
+ explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, dataPacket))
// Also broadcast with the numeric port number for backwards compatibility with some apps
- val numericAction = actionReceived(payload.dataType.toString())
+ val numericAction = actionReceived(dataPacket.dataType.toString())
if (numericAction != action) {
- explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, payload))
+ explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, dataPacket))
}
}
- fun broadcastNodeChange(info: NodeInfo) {
- Logger.d { "Broadcasting node change ${info.user?.toPIIString()}" }
- val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, info)
+ override fun broadcastNodeChange(node: Node) {
+ Logger.d { "Broadcasting node change ${node.user.toPIIString()}" }
+ val legacy = node.toLegacy()
+ val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, legacy)
explicitBroadcast(intent)
}
- fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status)
+ private fun Node.toLegacy(): NodeInfo = NodeInfo(
+ num = num,
+ user =
+ org.meshtastic.core.model.MeshUser(
+ id = user.id,
+ longName = user.long_name,
+ shortName = user.short_name,
+ hwModel = user.hw_model,
+ role = user.role.value,
+ ),
+ position =
+ org.meshtastic.core.model
+ .Position(
+ latitude = latitude,
+ longitude = longitude,
+ altitude = position.altitude ?: 0,
+ time = position.time,
+ satellitesInView = position.sats_in_view ?: 0,
+ groundSpeed = position.ground_speed ?: 0,
+ groundTrack = position.ground_track ?: 0,
+ precisionBits = position.precision_bits ?: 0,
+ )
+ .takeIf { latitude != 0.0 || longitude != 0.0 },
+ snr = snr,
+ rssi = rssi,
+ lastHeard = lastHeard,
+ deviceMetrics =
+ org.meshtastic.core.model.DeviceMetrics(
+ batteryLevel = deviceMetrics.battery_level ?: 0,
+ voltage = deviceMetrics.voltage ?: 0f,
+ channelUtilization = deviceMetrics.channel_utilization ?: 0f,
+ airUtilTx = deviceMetrics.air_util_tx ?: 0f,
+ uptimeSeconds = deviceMetrics.uptime_seconds ?: 0,
+ ),
+ channel = channel,
+ environmentMetrics = org.meshtastic.core.model.EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0),
+ hopsAway = hopsAway,
+ nodeStatus = nodeStatus,
+ )
- fun broadcastMessageStatus(id: Int, status: MessageStatus?) {
- if (id == 0) {
+ fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
+
+ override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) {
+ if (packetId == 0) {
Logger.d { "Ignoring anonymous packet status" }
} else {
// Do not log, contains PII possibly
// MeshService.Logger.d { "Broadcasting message status $p" }
val intent =
Intent(ACTION_MESSAGE_STATUS).apply {
- putExtra(EXTRA_PACKET_ID, id)
+ putExtra(EXTRA_PACKET_ID, packetId)
putExtra(EXTRA_STATUS, status as Parcelable)
}
explicitBroadcast(intent)
@@ -82,14 +124,13 @@ constructor(
}
/** Broadcast our current connection status */
- fun broadcastConnection() {
- val connectionState = connectionStateHolder.connectionState.value
+ override fun broadcastConnection() {
+ val connectionState = serviceRepository.connectionState.value
// ATAK expects a String: "CONNECTED" or "DISCONNECTED"
// It uses equalsIgnoreCase, but we'll use uppercase to be specific.
val stateStr = connectionState.toString().uppercase(Locale.ROOT)
val intent = Intent(ACTION_MESH_CONNECTED).apply { putExtra(EXTRA_CONNECTED, stateStr) }
- serviceRepository.setConnectionState(connectionState)
explicitBroadcast(intent)
if (connectionState == ConnectionState.Disconnected) {
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 f28f98114..f41dcd8e1 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -64,7 +64,6 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -85,7 +84,6 @@ import com.geeksville.mesh.navigation.firmwareGraph
import com.geeksville.mesh.navigation.mapGraph
import com.geeksville.mesh.navigation.nodesGraph
import com.geeksville.mesh.navigation.settingsGraph
-import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.connections.DeviceType
import com.geeksville.mesh.ui.connections.ScannerViewModel
@@ -98,6 +96,7 @@ import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceVersion
+import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MapRoutes
@@ -464,7 +463,6 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: ScannerVie
private fun VersionChecks(viewModel: UIViewModel) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
- val context = LocalContext.current
val myFirmwareVersion = myNodeInfo?.firmwareVersion
@@ -499,10 +497,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
viewModel.showAlert(
titleRes = Res.string.app_too_old,
messageRes = Res.string.must_update,
- onConfirm = {
- val service = viewModel.meshService ?: return@showAlert
- MeshService.changeDeviceAddress(context, service, "n")
- },
+ onConfirm = { viewModel.setDeviceAddress("n") },
)
} else {
myFirmwareVersion
@@ -526,10 +521,7 @@ private fun VersionChecks(viewModel: UIViewModel) {
viewModel.showAlert(
title = title,
html = message,
- onConfirm = {
- val service = viewModel.meshService ?: return@showAlert
- MeshService.changeDeviceAddress(context, service, "n")
- },
+ onConfirm = { viewModel.setDeviceAddress("n") },
)
} else if (curVer < MeshService.minDeviceVersion) {
Logger.w {
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt
index 88e9391f5..b17281ff6 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsViewModel.kt
@@ -21,12 +21,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.entity.MyNodeEntity
-import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.Node
import org.meshtastic.core.prefs.ui.UiPrefs
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
import javax.inject.Inject
@@ -46,7 +46,7 @@ constructor(
val connectionState = serviceRepository.connectionState
- val myNodeInfo: StateFlow = nodeRepository.myNodeInfo
+ val myNodeInfo: StateFlow = nodeRepository.myNodeInfo
val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt
index 131eb33e8..0bfba1faf 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ScannerViewModel.kt
@@ -16,18 +16,13 @@
*/
package com.geeksville.mesh.ui.connections
-import android.app.Application
-import android.content.Context
-import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import com.geeksville.mesh.domain.usecase.GetDiscoveredDevicesUseCase
import com.geeksville.mesh.model.DeviceListEntry
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.repository.usb.UsbRepository
-import com.geeksville.mesh.service.MeshService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -42,8 +37,10 @@ import kotlinx.coroutines.launch
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.anonymize
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import javax.inject.Inject
@@ -52,17 +49,14 @@ import javax.inject.Inject
class ScannerViewModel
@Inject
constructor(
- private val application: Application,
private val serviceRepository: ServiceRepository,
+ private val radioController: RadioController,
private val bluetoothRepository: BluetoothRepository,
private val usbRepository: UsbRepository,
private val radioInterfaceService: RadioInterfaceService,
private val recentAddressesDataSource: RecentAddressesDataSource,
private val getDiscoveredDevicesUseCase: GetDiscoveredDevicesUseCase,
) : ViewModel() {
- private val context: Context
- get() = application.applicationContext
-
val showMockInterface: StateFlow = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow()
private val _errorText = MutableStateFlow(null)
@@ -117,11 +111,8 @@ constructor(
}
private fun changeDeviceAddress(address: String) {
- try {
- serviceRepository.meshService?.let { service -> MeshService.changeDeviceAddress(context, service, address) }
- } catch (ex: RemoteException) {
- Logger.e(ex) { "changeDeviceSelection failed, probably it is shutting down" }
- }
+ Logger.i { "Attempting to change device address to ${address.anonymize()}" }
+ radioController.setDeviceAddress(address)
}
/** Initiates the bonding process and connects to the device upon success. */
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 eb359ca00..9bf5f3fbc 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
@@ -47,7 +47,7 @@ import no.nordicsemi.android.common.ui.view.RssiIcon
import no.nordicsemi.kotlin.ble.client.exception.OperationFailedException
import no.nordicsemi.kotlin.ble.client.exception.PeripheralNotConnectedException
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.disconnect
import org.meshtastic.core.resources.firmware_version
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt
index dc5f2a7b4..c5ba9bec4 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/ChannelViewModel.kt
@@ -17,7 +17,6 @@
package com.geeksville.mesh.ui.sharing
import android.net.Uri
-import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
@@ -27,9 +26,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
-import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.toChannelSet
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.Channel
@@ -42,12 +41,12 @@ import javax.inject.Inject
class ChannelViewModel
@Inject
constructor(
- private val serviceRepository: ServiceRepository,
+ private val radioController: RadioController,
private val radioConfigRepository: RadioConfigRepository,
private val analytics: PlatformAnalytics,
) : ViewModel() {
- val connectionState = serviceRepository.connectionState
+ val connectionState = radioController.connectionState
val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
@@ -95,20 +94,12 @@ constructor(
}
fun setChannel(channel: Channel) {
- try {
- serviceRepository.meshService?.setChannel(channel.encode())
- } catch (ex: RemoteException) {
- Logger.e(ex) { "Set channel error" }
- }
+ viewModelScope.launch { radioController.setLocalChannel(channel) }
}
// Set the radio config (also updates our saved copy in preferences)
fun setConfig(config: Config) {
- try {
- serviceRepository.meshService?.setConfig(config.encode())
- } catch (ex: RemoteException) {
- Logger.e(ex) { "Set config error" }
- }
+ viewModelScope.launch { radioController.setLocalConfig(config) }
}
fun trackShare() {
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
index eafbe38a2..1f28a65f7 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
@@ -28,11 +28,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.onlineTimeThreshold
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.LocalStats
import javax.inject.Inject
import javax.inject.Singleton
diff --git a/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt b/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
index 16d6b566e..6a044c90e 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/RefreshLocalStatsAction.kt
@@ -20,22 +20,22 @@ import android.content.Context
import androidx.glance.GlanceId
import androidx.glance.action.ActionParameters
import androidx.glance.appwidget.action.ActionCallback
-import com.geeksville.mesh.service.MeshCommandSender
-import com.geeksville.mesh.service.MeshNodeManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.NodeManager
class RefreshLocalStatsAction : ActionCallback {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface RefreshLocalStatsEntryPoint {
- fun commandSender(): MeshCommandSender
+ fun commandSender(): CommandSender
- fun nodeManager(): MeshNodeManager
+ fun nodeManager(): NodeManager
}
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
diff --git a/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt b/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt
index d980d265e..a468896fb 100644
--- a/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt
+++ b/app/src/main/java/com/geeksville/mesh/worker/ServiceKeepAliveWorker.kt
@@ -31,8 +31,8 @@ import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.startService
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.SERVICE_NOTIFY_ID
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
/**
* A worker whose sole purpose is to start the MeshService from the background. This is used as a fallback when
diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt
index eb4ac385d..41cceafe2 100644
--- a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceRetryTest.kt
@@ -44,6 +44,7 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
+import org.meshtastic.core.repository.RadioInterfaceService
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt
index 2974d3029..1ee5ff9ee 100644
--- a/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/repository/radio/NordicBleInterfaceTest.kt
@@ -47,6 +47,7 @@ import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
+import org.meshtastic.core.repository.RadioInterfaceService
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalCoroutinesApi::class)
@@ -662,7 +663,7 @@ class NordicBleInterfaceTest {
advanceUntilIdle()
// Verify handleFromRadio was called directly with the payload
- verify(timeout = 2000) { service.handleFromRadio(p = payload) }
+ verify(timeout = 2000) { service.handleFromRadio(payload) }
nordicInterface.close()
}
diff --git a/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt b/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt
index b0ddc037e..868c5197f 100644
--- a/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/repository/radio/StreamInterfaceTest.kt
@@ -20,6 +20,7 @@ import io.mockk.confirmVerified
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
+import org.meshtastic.core.repository.RadioInterfaceService
class StreamInterfaceTest {
diff --git a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt
index 19b187bdc..86ecc7fb9 100644
--- a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt
@@ -17,10 +17,10 @@
package com.geeksville.mesh.service
import android.app.Notification
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import io.mockk.mockk
-import org.meshtastic.core.database.entity.NodeEntity
-import org.meshtastic.core.service.MeshServiceNotifications
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
@@ -64,15 +64,15 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
- override fun showNewNodeSeenNotification(node: NodeEntity) {}
+ override fun showNewNodeSeenNotification(node: Node) {}
- override fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {}
+ override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
override fun showClientNotification(clientNotification: ClientNotification) {}
override fun cancelMessageNotification(contactKey: String) {}
- override fun cancelLowBatteryNotification(node: NodeEntity) {}
+ override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}
}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt
deleted file mode 100644
index 9b3aa4cfc..000000000
--- a/app/src/test/java/com/geeksville/mesh/service/MeshMessageProcessorTest.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * Copyright (c) 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 com.geeksville.mesh.service
-
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.verify
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.test.StandardTestDispatcher
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runTest
-import org.junit.Before
-import org.junit.Test
-import org.meshtastic.core.data.repository.MeshLogRepository
-import org.meshtastic.core.database.entity.MeshLog
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.proto.Data
-import org.meshtastic.proto.MeshPacket
-import org.meshtastic.proto.PortNum
-
-class MeshMessageProcessorTest {
-
- private val nodeManager: MeshNodeManager = mockk(relaxed = true)
- private val serviceRepository: ServiceRepository = mockk(relaxed = true)
- private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
- private val router: MeshRouter = mockk(relaxed = true)
- private val fromRadioDispatcher: FromRadioPacketHandler = mockk(relaxed = true)
- private val meshLogRepositoryLazy = dagger.Lazy { meshLogRepository }
- private val dataHandler: MeshDataHandler = mockk(relaxed = true)
-
- private val isNodeDbReady = MutableStateFlow(false)
- private val testDispatcher = StandardTestDispatcher()
- private val testScope = TestScope(testDispatcher)
-
- private lateinit var processor: MeshMessageProcessor
-
- @Before
- fun setUp() {
- every { nodeManager.isNodeDbReady } returns isNodeDbReady
- every { router.dataHandler } returns dataHandler
- processor =
- MeshMessageProcessor(nodeManager, serviceRepository, meshLogRepositoryLazy, router, fromRadioDispatcher)
- processor.start(testScope)
- }
-
- @Test
- fun `early packets are buffered and flushed when DB is ready`() = runTest(testDispatcher) {
- val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
-
- // 1. Database is NOT ready
- isNodeDbReady.value = false
- testScheduler.runCurrent() // trigger start() onEach
-
- processor.handleReceivedMeshPacket(packet, 999)
-
- // Verify that handleReceivedData has NOT been called yet
- verify(exactly = 0) { dataHandler.handleReceivedData(any(), any(), any(), any()) }
-
- // 2. Database becomes ready
- isNodeDbReady.value = true
- testScheduler.runCurrent() // trigger onEach(true)
-
- // Verify that handleReceivedData is now called with the buffered packet
- verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 123 }, any(), any(), any()) }
- }
-
- @Test
- fun `packets are processed immediately if DB is already ready`() = runTest(testDispatcher) {
- val packet = MeshPacket(id = 456, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
-
- isNodeDbReady.value = true
- testScheduler.runCurrent()
-
- processor.handleReceivedMeshPacket(packet, 999)
-
- verify(exactly = 1) { dataHandler.handleReceivedData(match { it.id == 456 }, any(), any(), any()) }
- }
-
- @Test
- fun `packets from local node are logged with NODE_NUM_LOCAL`() = runTest(testDispatcher) {
- val myNodeNum = 1234
- val packet = MeshPacket(from = myNodeNum, id = 789, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
-
- isNodeDbReady.value = true
- testScheduler.runCurrent()
-
- processor.handleReceivedMeshPacket(packet, myNodeNum)
- testScheduler.runCurrent() // wait for log insert job
-
- coVerify { meshLogRepository.insert(match { log -> log.fromNum == MeshLog.NODE_NUM_LOCAL }) }
- }
-
- @Test
- fun `packets from remote nodes are logged with their node number`() = runTest(testDispatcher) {
- val myNodeNum = 1234
- val remoteNodeNum = 5678
- val packet = MeshPacket(from = remoteNodeNum, id = 789, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
-
- isNodeDbReady.value = true
- testScheduler.runCurrent()
-
- processor.handleReceivedMeshPacket(packet, myNodeNum)
- testScheduler.runCurrent()
-
- coVerify { meshLogRepository.insert(match { log -> log.fromNum == remoteNodeNum }) }
- }
-}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt b/app/src/test/java/com/geeksville/mesh/service/ServiceBroadcastsTest.kt
similarity index 82%
rename from app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
rename to app/src/test/java/com/geeksville/mesh/service/ServiceBroadcastsTest.kt
index 88cee4a4b..3ddfecd61 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/ServiceBroadcastsTest.kt
@@ -19,35 +19,36 @@ package com.geeksville.mesh.service
import android.app.Application
import android.content.Context
import androidx.test.core.app.ApplicationProvider
+import io.mockk.every
import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.ConnectionState
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.ServiceRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
-class MeshServiceBroadcastsTest {
+class ServiceBroadcastsTest {
private lateinit var context: Context
- private val connectionStateHolder = ConnectionStateHandler()
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
- private lateinit var broadcasts: MeshServiceBroadcasts
+ private lateinit var broadcasts: ServiceBroadcasts
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
- broadcasts = MeshServiceBroadcasts(context, connectionStateHolder, serviceRepository)
+ broadcasts = ServiceBroadcasts(context, serviceRepository)
}
@Test
fun `broadcastConnection sends uppercase state string for ATAK`() {
- connectionStateHolder.setState(ConnectionState.Connected)
+ every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Connected)
broadcasts.broadcastConnection()
@@ -58,7 +59,7 @@ class MeshServiceBroadcastsTest {
@Test
fun `broadcastConnection sends legacy connection intent`() {
- connectionStateHolder.setState(ConnectionState.Connected)
+ every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Connected)
broadcasts.broadcastConnection()
diff --git a/compose_compiler_config.conf b/compose_compiler_config.conf
index 5952a81bd..032dc04e0 100644
--- a/compose_compiler_config.conf
+++ b/compose_compiler_config.conf
@@ -3,8 +3,8 @@
// For more information, check https://developer.android.com/jetpack/compose/performance/stability/fix#configuration-file
// Meshtastic Models
-org.meshtastic.core.database.model.Node
-org.meshtastic.core.database.model.Message
+org.meshtastic.core.model.Node
+org.meshtastic.core.model.Message
org.meshtastic.core.database.entity.Reaction
org.meshtastic.core.database.entity.ReactionEntity
org.meshtastic.core.model.**
diff --git a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt
index e58e804b6..8861b8a11 100644
--- a/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt
+++ b/core/ble/src/main/kotlin/org/meshtastic/core/ble/BluetoothRepository.kt
@@ -81,7 +81,7 @@ constructor(
@SuppressLint("MissingPermission")
suspend fun bond(peripheral: Peripheral) {
peripheral.createBond()
- refreshState()
+ updateBluetoothState()
}
internal suspend fun updateBluetoothState() {
@@ -112,6 +112,24 @@ constructor(
emptyList()
}
+ /** @return true if the given address is currently bonded to the system. */
+ @SuppressLint("MissingPermission")
+ fun isBonded(address: String): Boolean {
+ val enabled = androidEnvironment.isBluetoothEnabled
+ val hasPerms =
+ if (androidEnvironment.requiresBluetoothRuntimePermissions) {
+ androidEnvironment.isBluetoothScanPermissionGranted &&
+ androidEnvironment.isBluetoothConnectPermissionGranted
+ } else {
+ androidEnvironment.isLocationPermissionGranted
+ }
+ return if (enabled && hasPerms) {
+ centralManager.getBondedPeripherals().any { it.address == address }
+ } else {
+ false
+ }
+ }
+
/** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index 1f06437b6..90a438478 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -26,6 +26,7 @@ plugins {
configure { namespace = "org.meshtastic.core.data" }
dependencies {
+ api(projects.core.repository)
implementation(projects.core.analytics)
implementation(projects.core.common)
implementation(projects.core.database)
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt
new file mode 100644
index 000000000..333398c10
--- /dev/null
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/di/RepositoryModule.kt
@@ -0,0 +1,151 @@
+/*
+ * 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.data.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.data.manager.CommandSenderImpl
+import org.meshtastic.core.data.manager.FromRadioPacketHandlerImpl
+import org.meshtastic.core.data.manager.HistoryManagerImpl
+import org.meshtastic.core.data.manager.MeshActionHandlerImpl
+import org.meshtastic.core.data.manager.MeshConfigFlowManagerImpl
+import org.meshtastic.core.data.manager.MeshConfigHandlerImpl
+import org.meshtastic.core.data.manager.MeshConnectionManagerImpl
+import org.meshtastic.core.data.manager.MeshDataHandlerImpl
+import org.meshtastic.core.data.manager.MeshMessageProcessorImpl
+import org.meshtastic.core.data.manager.MeshRouterImpl
+import org.meshtastic.core.data.manager.MessageFilterImpl
+import org.meshtastic.core.data.manager.MqttManagerImpl
+import org.meshtastic.core.data.manager.NeighborInfoHandlerImpl
+import org.meshtastic.core.data.manager.NodeManagerImpl
+import org.meshtastic.core.data.manager.PacketHandlerImpl
+import org.meshtastic.core.data.manager.TracerouteHandlerImpl
+import org.meshtastic.core.data.repository.DeviceHardwareRepositoryImpl
+import org.meshtastic.core.data.repository.NodeRepositoryImpl
+import org.meshtastic.core.data.repository.PacketRepositoryImpl
+import org.meshtastic.core.data.repository.RadioConfigRepositoryImpl
+import org.meshtastic.core.model.util.MeshDataMapper
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.DeviceHardwareRepository
+import org.meshtastic.core.repository.FromRadioPacketHandler
+import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.MeshActionHandler
+import org.meshtastic.core.repository.MeshConfigFlowManager
+import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.MeshDataHandler
+import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshRouter
+import org.meshtastic.core.repository.MessageFilter
+import org.meshtastic.core.repository.MqttManager
+import org.meshtastic.core.repository.NeighborInfoHandler
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.TracerouteHandler
+import javax.inject.Singleton
+
+@Suppress("TooManyFunctions")
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RepositoryModule {
+
+ @Binds @Singleton
+ abstract fun bindNodeRepository(nodeRepositoryImpl: NodeRepositoryImpl): NodeRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindRadioConfigRepository(radioConfigRepositoryImpl: RadioConfigRepositoryImpl): RadioConfigRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindDeviceHardwareRepository(
+ deviceHardwareRepositoryImpl: DeviceHardwareRepositoryImpl,
+ ): DeviceHardwareRepository
+
+ @Binds @Singleton
+ abstract fun bindPacketRepository(packetRepositoryImpl: PacketRepositoryImpl): PacketRepository
+
+ @Binds @Singleton
+ abstract fun bindNodeManager(nodeManagerImpl: NodeManagerImpl): NodeManager
+
+ @Binds @Singleton
+ abstract fun bindCommandSender(commandSenderImpl: CommandSenderImpl): CommandSender
+
+ @Binds @Singleton
+ abstract fun bindHistoryManager(historyManagerImpl: HistoryManagerImpl): HistoryManager
+
+ @Binds
+ @Singleton
+ abstract fun bindTracerouteHandler(tracerouteHandlerImpl: TracerouteHandlerImpl): TracerouteHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindNeighborInfoHandler(neighborInfoHandlerImpl: NeighborInfoHandlerImpl): NeighborInfoHandler
+
+ @Binds @Singleton
+ abstract fun bindMqttManager(mqttManagerImpl: MqttManagerImpl): MqttManager
+
+ @Binds @Singleton
+ abstract fun bindPacketHandler(packetHandlerImpl: PacketHandlerImpl): PacketHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindMeshConnectionManager(meshConnectionManagerImpl: MeshConnectionManagerImpl): MeshConnectionManager
+
+ @Binds @Singleton
+ abstract fun bindMeshDataHandler(meshDataHandlerImpl: MeshDataHandlerImpl): MeshDataHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindMeshActionHandler(meshActionHandlerImpl: MeshActionHandlerImpl): MeshActionHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindMeshMessageProcessor(meshMessageProcessorImpl: MeshMessageProcessorImpl): MeshMessageProcessor
+
+ @Binds @Singleton
+ abstract fun bindMeshRouter(meshRouterImpl: MeshRouterImpl): MeshRouter
+
+ @Binds
+ @Singleton
+ abstract fun bindFromRadioPacketHandler(
+ fromRadioPacketHandlerImpl: FromRadioPacketHandlerImpl,
+ ): FromRadioPacketHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindMeshConfigHandler(meshConfigHandlerImpl: MeshConfigHandlerImpl): MeshConfigHandler
+
+ @Binds
+ @Singleton
+ abstract fun bindMeshConfigFlowManager(meshConfigFlowManagerImpl: MeshConfigFlowManagerImpl): MeshConfigFlowManager
+
+ @Binds @Singleton
+ abstract fun bindMessageFilter(messageFilterImpl: MessageFilterImpl): MessageFilter
+
+ companion object {
+ @Provides
+ @Singleton
+ fun provideMeshDataMapper(nodeManager: NodeManager): MeshDataMapper = MeshDataMapper(nodeManager)
+ }
+}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.kt
new file mode 100644
index 000000000..8093d73e9
--- /dev/null
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/di/UseCaseModule.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.data.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.repository.HomoglyphPrefs
+import org.meshtastic.core.repository.MessageQueue
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.usecase.SendMessageUseCase
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object UseCaseModule {
+
+ @Provides
+ @Singleton
+ fun provideSendMessageUseCase(
+ nodeRepository: NodeRepository,
+ packetRepository: PacketRepository,
+ radioController: RadioController,
+ homoglyphEncodingPrefs: HomoglyphPrefs,
+ messageQueue: MessageQueue,
+ ): SendMessageUseCase =
+ SendMessageUseCase(nodeRepository, packetRepository, radioController, homoglyphEncodingPrefs, messageQueue)
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
similarity index 77%
rename from app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
index 6e98b253e..4f262071c 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
@@ -14,10 +14,8 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
-import android.os.RemoteException
-import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -28,14 +26,15 @@ import kotlinx.coroutines.flow.onEach
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
@@ -54,55 +53,56 @@ import javax.inject.Singleton
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.hours
-@Suppress("TooManyFunctions")
+@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
-class MeshCommandSender
+class CommandSenderImpl
@Inject
constructor(
- private val packetHandler: PacketHandler?,
- private val nodeManager: MeshNodeManager?,
- private val connectionStateHolder: ConnectionStateHandler?,
- private val radioConfigRepository: RadioConfigRepository?,
-) {
+ private val packetHandler: PacketHandler,
+ private val nodeManager: NodeManager,
+ private val radioConfigRepository: RadioConfigRepository,
+) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val currentPacketId = AtomicLong(java.util.Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = AtomicReference(ByteString.EMPTY)
- val tracerouteStartTimes = ConcurrentHashMap()
- val neighborInfoStartTimes = ConcurrentHashMap()
+ override val tracerouteStartTimes = ConcurrentHashMap()
+ override val neighborInfoStartTimes = ConcurrentHashMap()
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
- @Volatile var lastNeighborInfo: NeighborInfo? = null
+ override var lastNeighborInfo: NeighborInfo? = null
- fun start(scope: CoroutineScope) {
+ // We'll need a way to track connection state in shared code,
+ // maybe via ServiceRepository or similar.
+ // For now I'll assume it's injected or available.
+
+ override fun start(scope: CoroutineScope) {
this.scope = scope
- radioConfigRepository?.localConfigFlow?.onEach { localConfig.value = it }?.launchIn(scope)
- radioConfigRepository?.channelSetFlow?.onEach { channelSet.value = it }?.launchIn(scope)
+ radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope)
+ radioConfigRepository.channelSetFlow.onEach { channelSet.value = it }.launchIn(scope)
}
- fun getCachedLocalConfig(): LocalConfig = localConfig.value
+ override fun getCachedLocalConfig(): LocalConfig = localConfig.value
- fun getCachedChannelSet(): ChannelSet = channelSet.value
+ override fun getCachedChannelSet(): ChannelSet = channelSet.value
- @VisibleForTesting internal constructor() : this(null, null, null, null)
+ override fun getCurrentPacketId(): Long = currentPacketId.get()
- fun getCurrentPacketId(): Long = currentPacketId.get()
-
- fun generatePacketId(): Int {
+ override fun generatePacketId(): Int {
val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1)
val next = currentPacketId.incrementAndGet() and PACKET_ID_MASK
return ((next % numPacketIds) + 1L).toInt()
}
- fun setSessionPasskey(key: ByteString) {
+ override fun setSessionPasskey(key: ByteString) {
sessionPasskey.set(key)
}
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
private fun getAdminChannelIndex(toNum: Int): Int {
- val myNum = nodeManager?.myNodeNum ?: return 0
+ val myNum = nodeManager.myNodeNum ?: return 0
val myNode = nodeManager.nodeDBbyNodeNum[myNum]
val destNode = nodeManager.nodeDBbyNodeNum[toNum]
@@ -118,7 +118,7 @@ constructor(
return adminChannelIndex
}
- fun sendData(p: DataPacket) {
+ override fun sendData(p: DataPacket) {
if (p.id == 0) p.id = generatePacketId()
val bytes = p.bytes ?: ByteString.EMPTY
require(p.dataType != 0) { "Port numbers must be non-zero!" }
@@ -135,16 +135,15 @@ constructor(
if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) {
val actualSize = Data.ADAPTER.encodedSize(data)
p.status = MessageStatus.ERROR
- throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})")
+ // throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})")
+ // RemoteException is Android specific. For KMP we might want a custom exception.
+ error("Message too long: $actualSize bytes")
} else {
p.status = MessageStatus.QUEUED
}
- if (connectionStateHolder?.connectionState?.value == ConnectionState.Connected) {
- sendNow(p)
- } else {
- error("Radio is not connected")
- }
+ // TODO: Check connection state
+ sendNow(p)
}
private fun sendNow(p: DataPacket) {
@@ -164,31 +163,26 @@ constructor(
),
)
p.time = nowMillis
- packetHandler?.sendToRadio(meshPacket)
+ packetHandler.sendToRadio(meshPacket)
}
- fun sendAdmin(
- destNum: Int,
- requestId: Int = generatePacketId(),
- wantResponse: Boolean = false,
- initFn: () -> AdminMessage,
- ) {
+ override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
val adminMsg = initFn().copy(session_passkey = sessionPasskey.get())
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
- packetHandler?.sendToRadio(packet)
+ packetHandler.sendToRadio(packet)
}
- fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false) {
- val myNum = nodeManager?.myNodeNum ?: return
+ override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {
+ val myNum = nodeManager.myNodeNum ?: return
val idNum = destNum ?: myNum
Logger.d { "Sending our position/time to=$idNum $pos" }
if (localConfig.value.position?.fixed_position != true) {
- nodeManager.handleReceivedPosition(myNum, myNum, pos)
+ nodeManager.handleReceivedPosition(myNum, myNum, pos, nowMillis)
}
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = idNum,
channel = if (destNum == null) 0 else nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
@@ -203,18 +197,18 @@ constructor(
)
}
- fun requestPosition(destNum: Int, currentPosition: Position) {
+ override fun requestPosition(destNum: Int, currentPosition: Position) {
val meshPosition =
org.meshtastic.proto.Position(
latitude_i = Position.degI(currentPosition.latitude),
longitude_i = Position.degI(currentPosition.longitude),
altitude = currentPosition.altitude,
- time = nowSeconds.toInt(),
+ time = (nowMillis / 1000L).toInt(),
)
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
- channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
priority = MeshPacket.Priority.BACKGROUND,
decoded =
Data(
@@ -226,7 +220,7 @@ constructor(
)
}
- fun setFixedPosition(destNum: Int, pos: Position) {
+ override fun setFixedPosition(destNum: Int, pos: Position) {
val meshPos =
org.meshtastic.proto.Position(
latitude_i = Position.degI(pos.latitude),
@@ -240,13 +234,13 @@ constructor(
AdminMessage(remove_fixed_position = true)
}
}
- nodeManager?.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos)
+ nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum ?: 0, meshPos, nowMillis)
}
- fun requestUserInfo(destNum: Int) {
- val myNum = nodeManager?.myNodeNum ?: return
- val myNode = nodeManager.getOrCreateNodeInfo(myNum)
- packetHandler?.sendToRadio(
+ override fun requestUserInfo(destNum: Int) {
+ val myNum = nodeManager.myNodeNum ?: return
+ val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
@@ -260,20 +254,20 @@ constructor(
)
}
- fun requestTraceroute(requestId: Int, destNum: Int) {
+ override fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteStartTimes[requestId] = nowMillis
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
- channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true),
),
)
}
- fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
+ override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
val portNum: PortNum
@@ -301,19 +295,19 @@ constructor(
.toByteString()
}
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
id = requestId,
- channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true),
),
)
}
- fun requestNeighborInfo(requestId: Int, destNum: Int) {
+ override fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoStartTimes[requestId] = nowMillis
- val myNum = nodeManager?.myNodeNum ?: 0
+ val myNum = nodeManager.myNodeNum ?: 0
if (destNum == myNum) {
val neighborInfoToSend =
lastNeighborInfo
@@ -329,7 +323,7 @@ constructor(
Neighbor(
node_id = 0, // Dummy node ID that can be intercepted
snr = 0f,
- last_rx_time = nowSeconds.toInt(),
+ last_rx_time = (nowMillis / 1000L).toInt(),
node_broadcast_interval_secs = oneHour,
),
),
@@ -337,12 +331,12 @@ constructor(
}
// Send the neighbor info from our connected radio to ourselves (simulated)
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
- channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded =
Data(
portnum = PortNum.NEIGHBORINFO_APP,
@@ -353,20 +347,19 @@ constructor(
)
} else {
// Send request to remote
- packetHandler?.sendToRadio(
+ packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
- channel = nodeManager?.nodeDBbyNodeNum?.get(destNum)?.channel ?: 0,
+ channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true),
),
)
}
}
- @VisibleForTesting
- internal fun resolveNodeNum(toId: String): Int = when (toId) {
+ fun resolveNodeNum(toId: String): Int = when (toId) {
DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
else -> {
val numericNum =
@@ -376,7 +369,7 @@ constructor(
null
}
numericNum
- ?: nodeManager?.nodeDBbyID?.get(toId)?.num
+ ?: nodeManager.nodeDBbyID[toId]?.num
?: throw IllegalArgumentException("Unknown node ID $toId")
}
}
@@ -398,12 +391,12 @@ constructor(
if (channel == DataPacket.PKC_CHANNEL_INDEX) {
pkiEncrypted = true
- publicKey = nodeManager?.nodeDBbyNodeNum?.get(to)?.user?.public_key ?: ByteString.EMPTY
+ publicKey = nodeManager.nodeDBbyNodeNum[to]?.user?.public_key ?: ByteString.EMPTY
actualChannel = 0
}
return MeshPacket(
- from = nodeManager?.myNodeNum ?: 0,
+ from = nodeManager.myNodeNum ?: 0,
to = to,
id = id,
want_ack = wantAck,
diff --git a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
similarity index 53%
rename from app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
index a771b6fa2..081d1a207 100644
--- a/app/src/main/java/com/geeksville/mesh/service/FromRadioPacketHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt
@@ -14,31 +14,32 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
-import co.touchlab.kermit.Logger
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceRepository
+import dagger.Lazy
+import org.meshtastic.core.repository.FromRadioPacketHandler
+import org.meshtastic.core.repository.MeshRouter
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.MqttManager
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import javax.inject.Inject
import javax.inject.Singleton
-/**
- * Dispatches non-packet [FromRadio] variants to their respective handlers. This class is stateless and handles routing
- * for config, metadata, and specialized system messages.
- */
+/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
@Singleton
-class FromRadioPacketHandler
+class FromRadioPacketHandlerImpl
@Inject
constructor(
private val serviceRepository: ServiceRepository,
- private val router: MeshRouter,
- private val mqttManager: MeshMqttManager,
+ private val router: Lazy,
+ private val mqttManager: MqttManager,
private val packetHandler: PacketHandler,
private val serviceNotifications: MeshServiceNotifications,
-) {
+) : FromRadioPacketHandler {
@Suppress("CyclomaticComplexMethod")
- fun handleFromRadio(proto: FromRadio) {
+ override fun handleFromRadio(proto: FromRadio) {
val myInfo = proto.my_info
val metadata = proto.metadata
val nodeInfo = proto.node_info
@@ -51,34 +52,23 @@ constructor(
val clientNotification = proto.clientNotification
when {
- myInfo != null -> router.configFlowManager.handleMyInfo(myInfo)
- metadata != null -> router.configFlowManager.handleLocalMetadata(metadata)
+ myInfo != null -> router.get().configFlowManager.handleMyInfo(myInfo)
+ metadata != null -> router.get().configFlowManager.handleLocalMetadata(metadata)
nodeInfo != null -> {
- router.configFlowManager.handleNodeInfo(nodeInfo)
- serviceRepository.setConnectionProgress("Nodes (${router.configFlowManager.newNodeCount})")
+ router.get().configFlowManager.handleNodeInfo(nodeInfo)
+ serviceRepository.setConnectionProgress("Nodes (${router.get().configFlowManager.newNodeCount})")
}
- configCompleteId != null -> router.configFlowManager.handleConfigComplete(configCompleteId)
+ configCompleteId != null -> router.get().configFlowManager.handleConfigComplete(configCompleteId)
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
queueStatus != null -> packetHandler.handleQueueStatus(queueStatus)
- config != null -> router.configHandler.handleDeviceConfig(config)
- moduleConfig != null -> router.configHandler.handleModuleConfig(moduleConfig)
- channel != null -> router.configHandler.handleChannel(channel)
+ config != null -> router.get().configHandler.handleDeviceConfig(config)
+ moduleConfig != null -> router.get().configHandler.handleModuleConfig(moduleConfig)
+ channel != null -> router.get().configHandler.handleChannel(channel)
clientNotification != null -> {
serviceRepository.setClientNotification(clientNotification)
serviceNotifications.showClientNotification(clientNotification)
- packetHandler.removeResponse(clientNotification.reply_id ?: 0, complete = false)
+ packetHandler.removeResponse(0, complete = false)
}
- // Logging-only variants are handled by MeshMessageProcessor before dispatching here
- proto.packet != null ||
- proto.log_record != null ||
- proto.rebooted != null ||
- proto.xmodemPacket != null ||
- proto.deviceuiConfig != null ||
- proto.fileInfo != null -> {
- /* No specialized routing needed here */
- }
-
- else -> Logger.d { "Dispatcher ignoring FromRadio variant" }
}
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
similarity index 74%
rename from app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
index b084433b4..a2df3d73a 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshHistoryManager.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
@@ -14,15 +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.service
+package org.meshtastic.core.data.manager
-import android.util.Log
-import androidx.annotation.VisibleForTesting
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.BuildConfig
-import com.geeksville.mesh.ui.connections.NO_DEVICE_SELECTED
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.prefs.mesh.MeshPrefs
+import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
@@ -32,19 +30,20 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshHistoryManager
+class HistoryManagerImpl
@Inject
constructor(
private val meshPrefs: MeshPrefs,
private val packetHandler: PacketHandler,
-) {
+) : HistoryManager {
+
companion object {
private const val HISTORY_TAG = "HistoryReplay"
private const val DEFAULT_HISTORY_RETURN_WINDOW_MINUTES = 60 * 24
private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100
+ private const val NO_DEVICE_SELECTED = "No device selected"
- @VisibleForTesting
- internal fun buildStoreForwardHistoryRequest(
+ fun buildStoreForwardHistoryRequest(
lastRequest: Int,
historyReturnWindow: Int,
historyReturnMax: Int,
@@ -58,32 +57,23 @@ constructor(
return StoreAndForward(rr = StoreAndForward.RequestResponse.CLIENT_HISTORY, history = history)
}
- @VisibleForTesting
- internal fun resolveHistoryRequestParameters(window: Int, max: Int): Pair {
+ fun resolveHistoryRequestParameters(window: Int, max: Int): Pair {
val resolvedWindow = if (window > 0) window else DEFAULT_HISTORY_RETURN_WINDOW_MINUTES
val resolvedMax = if (max > 0) max else DEFAULT_HISTORY_RETURN_MAX_MESSAGES
return resolvedWindow to resolvedMax
}
}
- private fun historyLog(priority: Int = Log.INFO, throwable: Throwable? = null, message: () -> String) {
- if (!BuildConfig.DEBUG) return
- val logger = Logger.withTag(HISTORY_TAG)
- val msg = message()
- when (priority) {
- Log.VERBOSE -> logger.v(throwable) { msg }
- Log.DEBUG -> logger.d(throwable) { msg }
- Log.INFO -> logger.i(throwable) { msg }
- Log.WARN -> logger.w(throwable) { msg }
- Log.ERROR -> logger.e(throwable) { msg }
- else -> logger.i(throwable) { msg }
- }
+ private val logger = Logger.withTag(HISTORY_TAG)
+
+ private fun historyLog(message: String, throwable: Throwable? = null) {
+ logger.i(throwable) { message }
}
private fun activeDeviceAddress(): String? =
meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
- fun requestHistoryReplay(
+ override fun requestHistoryReplay(
trigger: String,
myNodeNum: Int?,
storeForwardConfig: ModuleConfig.StoreForwardConfig?,
@@ -92,7 +82,7 @@ constructor(
val address = activeDeviceAddress()
if (address == null || myNodeNum == null) {
val reason = if (address == null) "no_addr" else "no_my_node"
- historyLog { "requestHistory skipped trigger=$trigger reason=$reason" }
+ historyLog("requestHistory skipped trigger=$trigger reason=$reason")
return
}
@@ -105,10 +95,10 @@ constructor(
val request = buildStoreForwardHistoryRequest(lastRequest, window, max)
- historyLog {
+ historyLog(
"requestHistory trigger=$trigger transport=$transport addr=$address " +
- "lastRequest=$lastRequest window=$window max=$max"
- }
+ "lastRequest=$lastRequest window=$window max=$max",
+ )
runCatching {
packetHandler.sendToRadio(
@@ -120,19 +110,19 @@ constructor(
),
)
}
- .onFailure { ex -> historyLog(Log.WARN, ex) { "requestHistory failed" } }
+ .onFailure { ex -> logger.w(ex) { "requestHistory failed" } }
}
- fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) {
+ override fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) {
if (lastRequest <= 0) return
val address = activeDeviceAddress() ?: return
val current = meshPrefs.getStoreForwardLastRequest(address)
if (lastRequest != current) {
meshPrefs.setStoreForwardLastRequest(address, lastRequest)
- historyLog {
+ historyLog(
"historyMarker updated source=$source transport=$transport " +
- "addr=$address from=$current to=$lastRequest"
- }
+ "addr=$address from=$current to=$lastRequest",
+ )
}
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
similarity index 74%
rename from app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
index 5ac1ee1cf..0adf6a80e 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.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.service
+package org.meshtastic.core.data.manager
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
@@ -26,15 +26,22 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.database.DatabaseManager
-import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.Reaction
+import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.prefs.mesh.MeshPrefs
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceAction
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.DatabaseManager
+import org.meshtastic.core.repository.MeshActionHandler
+import org.meshtastic.core.repository.MeshDataHandler
+import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
@@ -47,23 +54,23 @@ import javax.inject.Singleton
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
-class MeshActionHandler
+class MeshActionHandlerImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
- private val commandSender: MeshCommandSender,
+ private val nodeManager: NodeManager,
+ private val commandSender: CommandSender,
private val packetRepository: Lazy,
- private val serviceBroadcasts: MeshServiceBroadcasts,
- private val dataHandler: MeshDataHandler,
+ private val serviceBroadcasts: ServiceBroadcasts,
+ private val dataHandler: Lazy,
private val analytics: PlatformAnalytics,
private val meshPrefs: MeshPrefs,
private val databaseManager: DatabaseManager,
private val serviceNotifications: MeshServiceNotifications,
private val messageProcessor: Lazy,
-) {
+) : MeshActionHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
@@ -72,7 +79,7 @@ constructor(
private const val EMOJI_INDICATOR = 1
}
- fun onServiceAction(action: ServiceAction) {
+ override fun onServiceAction(action: ServiceAction) {
ignoreException {
val myNodeNum = nodeManager.myNodeNum ?: return@ignoreException
when (action) {
@@ -102,7 +109,7 @@ constructor(
AdminMessage(set_favorite_node = node.num)
}
}
- nodeManager.updateNodeInfo(node.num) { it.isFavorite = !node.isFavorite }
+ nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) }
}
private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) {
@@ -115,14 +122,14 @@ constructor(
AdminMessage(remove_ignored_node = node.num)
}
}
- nodeManager.updateNodeInfo(node.num) { it.isIgnored = newIgnoredStatus }
+ nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnoredStatus) }
scope.handledLaunch { packetRepository.get().updateFilteredBySender(node.user.id, newIgnoredStatus) }
}
private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) }
- nodeManager.updateNodeInfo(node.num) { it.isMuted = !node.isMuted }
+ nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) }
}
private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) {
@@ -147,7 +154,7 @@ constructor(
val verifiedContact = action.contact.copy(manually_verified = true)
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) }
nodeManager.handleReceivedUser(
- verifiedContact.node_num ?: 0,
+ verifiedContact.node_num,
verifiedContact.user ?: User(),
manuallyVerified = true,
)
@@ -155,11 +162,11 @@ constructor(
private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) {
scope.handledLaunch {
+ val user = nodeManager.nodeDBbyNodeNum[myNodeNum]?.user ?: User(id = nodeManager.getMyId())
val reaction =
- ReactionEntity(
- myNodeNum = myNodeNum,
+ Reaction(
replyId = action.replyId,
- userId = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL,
+ user = user,
emoji = action.emoji,
timestamp = nowMillis,
snr = 0f,
@@ -170,25 +177,25 @@ constructor(
to = action.contactKey.substring(1),
channel = action.contactKey[0].digitToInt(),
)
- packetRepository.get().insertReaction(reaction)
+ packetRepository.get().insertReaction(reaction, myNodeNum)
}
}
- fun handleSetOwner(u: org.meshtastic.core.model.MeshUser, myNodeNum: Int) {
+ override fun handleSetOwner(u: MeshUser, myNodeNum: Int) {
val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) }
nodeManager.handleReceivedUser(myNodeNum, newUser)
}
- fun handleSend(p: DataPacket, myNodeNum: Int) {
+ override fun handleSend(p: DataPacket, myNodeNum: Int) {
commandSender.sendData(p)
- serviceBroadcasts.broadcastMessageStatus(p)
- dataHandler.rememberDataPacket(p, myNodeNum, false)
+ serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
+ dataHandler.get().rememberDataPacket(p, myNodeNum, false)
val bytes = p.bytes ?: okio.ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
}
- fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
+ override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
if (destNum != myNodeNum) {
val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum)
val currentPosition =
@@ -201,32 +208,32 @@ constructor(
}
}
- fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) {
+ override fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) {
nodeManager.removeByNodenum(nodeNum)
commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) }
}
- fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) {
+ override fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) {
val u = User.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) }
nodeManager.handleReceivedUser(destNum, u)
}
- fun handleGetRemoteOwner(id: Int, destNum: Int) {
+ override fun handleGetRemoteOwner(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) }
}
- fun handleSetConfig(payload: ByteArray, myNodeNum: Int) {
+ override fun handleSetConfig(payload: ByteArray, myNodeNum: Int) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) }
}
- fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) {
+ override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) }
}
- fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) {
+ override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) {
AdminMessage(get_device_metadata_request = true)
@@ -236,104 +243,104 @@ constructor(
}
}
- fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) {
+ override fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = ModuleConfig.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) }
c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) }
}
- fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {
+ override fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config))
}
}
- fun handleSetRingtone(destNum: Int, ringtone: String) {
+ override fun handleSetRingtone(destNum: Int, ringtone: String) {
commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) }
}
- fun handleGetRingtone(id: Int, destNum: Int) {
+ override fun handleGetRingtone(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) }
}
- fun handleSetCannedMessages(destNum: Int, messages: String) {
+ override fun handleSetCannedMessages(destNum: Int, messages: String) {
commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) }
}
- fun handleGetCannedMessages(id: Int, destNum: Int) {
+ override fun handleGetCannedMessages(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_canned_message_module_messages_request = true)
}
}
- fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) {
+ override fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) {
if (payload != null) {
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) }
}
}
- fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) {
+ override fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) {
if (payload != null) {
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) }
}
}
- fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) {
+ override fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) }
}
- fun handleRequestNeighborInfo(requestId: Int, destNum: Int) {
+ override fun handleRequestNeighborInfo(requestId: Int, destNum: Int) {
commandSender.requestNeighborInfo(requestId, destNum)
}
- fun handleBeginEditSettings(destNum: Int) {
+ override fun handleBeginEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) }
}
- fun handleCommitEditSettings(destNum: Int) {
+ override fun handleCommitEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) }
}
- fun handleRebootToDfu(destNum: Int) {
+ override fun handleRebootToDfu(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) }
}
- fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) {
+ override fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) {
commandSender.requestTelemetry(requestId, destNum, type)
}
- fun handleRequestShutdown(requestId: Int, destNum: Int) {
+ override fun handleRequestShutdown(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) }
}
- fun handleRequestReboot(requestId: Int, destNum: Int) {
+ override fun handleRequestReboot(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) }
}
- fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
+ override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA
val otaEvent =
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.ByteString.EMPTY)
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
}
- fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
+ override fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) }
}
- fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {
+ override fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) }
}
- fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) {
+ override fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId, wantResponse = true) {
AdminMessage(get_device_connection_status_request = true)
}
}
- fun handleUpdateLastAddress(deviceAddr: String?) {
+ override fun handleUpdateLastAddress(deviceAddr: String?) {
val currentAddr = meshPrefs.deviceAddress
if (deviceAddr != currentAddr) {
meshPrefs.deviceAddress = deviceAddr
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
similarity index 73%
rename from app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
index 1d666ca2d..86026b9be 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
@@ -14,64 +14,71 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
+import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.entity.MetadataEntity
-import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.MeshConfigFlowManager
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat
-import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.ToRadio
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
+import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
+import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
-@Suppress("LongParameterList")
+@Suppress("LongParameterList", "TooManyFunctions")
@Singleton
-class MeshConfigFlowManager
+class MeshConfigFlowManagerImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
- private val connectionManager: MeshConnectionManager,
+ private val nodeManager: NodeManager,
+ private val connectionManager: Lazy,
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
- private val connectionStateHolder: ConnectionStateHandler,
- private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val serviceRepository: ServiceRepository,
+ private val serviceBroadcasts: ServiceBroadcasts,
private val analytics: PlatformAnalytics,
- private val commandSender: MeshCommandSender,
+ private val commandSender: CommandSender,
private val packetHandler: PacketHandler,
-) {
+) : MeshConfigFlowManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val configOnlyNonce = 69420
private val nodeInfoNonce = 69421
private val wantConfigDelay = 100L
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
private val newNodes = mutableListOf()
- val newNodeCount: Int
+ override val newNodeCount: Int
get() = newNodes.size
- private var rawMyNodeInfo: MyNodeInfo? = null
+ private var rawMyNodeInfo: ProtoMyNodeInfo? = null
private var lastMetadata: DeviceMetadata? = null
- private var newMyNodeInfo: MyNodeEntity? = null
- private var myNodeInfo: MyNodeEntity? = null
+ private var newMyNodeInfo: SharedMyNodeInfo? = null
+ private var myNodeInfo: SharedMyNodeInfo? = null
- fun handleConfigComplete(configCompleteId: Int) {
+ override fun handleConfigComplete(configCompleteId: Int) {
when (configCompleteId) {
configOnlyNonce -> handleConfigOnlyComplete()
nodeInfoNonce -> handleNodeInfoComplete()
@@ -94,7 +101,7 @@ constructor(
} else {
myNodeInfo = finalizedInfo
Logger.i { "myNodeInfo committed successfully (nodeNum=${finalizedInfo.myNodeNum})" }
- connectionManager.onRadioConfigLoaded()
+ connectionManager.get().onRadioConfigLoaded()
}
scope.handledLaunch {
@@ -102,7 +109,7 @@ constructor(
sendHeartbeat()
delay(wantConfigDelay)
Logger.i { "Requesting NodeInfo (Stage 2)" }
- connectionManager.startNodeInfoOnly()
+ connectionManager.get().startNodeInfoOnly()
}
}
@@ -129,19 +136,19 @@ constructor(
nodeRepository.installConfig(it, entities)
sendAnalytics(it)
}
- nodeManager.isNodeDbReady.value = true
- nodeManager.allowNodeDbWrites.value = true
- connectionStateHolder.setState(ConnectionState.Connected)
+ nodeManager.setNodeDbReady(true)
+ nodeManager.setAllowNodeDbWrites(true)
+ serviceRepository.setConnectionState(ConnectionState.Connected)
serviceBroadcasts.broadcastConnection()
- connectionManager.onNodeDbReady()
+ connectionManager.get().onNodeDbReady()
}
}
- private fun sendAnalytics(mi: MyNodeEntity) {
+ private fun sendAnalytics(mi: SharedMyNodeInfo) {
analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown")
}
- fun handleMyInfo(myInfo: MyNodeInfo) {
+ override fun handleMyInfo(myInfo: ProtoMyNodeInfo) {
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
rawMyNodeInfo = myInfo
nodeManager.myNodeNum = myInfo.my_node_num
@@ -154,24 +161,29 @@ constructor(
}
}
- fun handleLocalMetadata(metadata: DeviceMetadata) {
+ override fun handleLocalMetadata(metadata: DeviceMetadata) {
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
lastMetadata = metadata
regenMyNodeInfo(metadata)
}
- fun handleNodeInfo(info: NodeInfo) {
+ override fun handleNodeInfo(info: NodeInfo) {
newNodes.add(info)
}
+ override fun triggerWantConfig() {
+ connectionManager.get().startConfigOnly()
+ }
+
private fun regenMyNodeInfo(metadata: DeviceMetadata? = null) {
val myInfo = rawMyNodeInfo
if (myInfo != null) {
try {
val mi =
with(myInfo) {
- MyNodeEntity(
- myNodeNum = my_node_num ?: 0,
+ SharedMyNodeInfo(
+ myNodeNum = my_node_num,
+ hasGPS = false,
model =
when (val hwModel = metadata?.hw_model) {
null,
@@ -187,12 +199,14 @@ constructor(
minAppVersion = min_app_version,
maxChannels = 8,
hasWifi = metadata?.hasWifi == true,
+ channelUtilization = 0f,
+ airUtilTx = 0f,
deviceId = device_id.utf8(),
pioEnv = myInfo.pio_env.ifEmpty { null },
)
}
if (metadata != null && metadata != DeviceMetadata()) {
- scope.handledLaunch { nodeRepository.insertMetadata(MetadataEntity(mi.myNodeNum, metadata)) }
+ scope.handledLaunch { nodeRepository.insertMetadata(mi.myNodeNum, metadata) }
}
newMyNodeInfo = mi
Logger.d { "newMyNodeInfo updated: nodeNum=${mi.myNodeNum} model=${mi.model} fw=${mi.firmwareVersion}" }
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
similarity index 79%
rename from app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
index 616529d14..d5ff32426 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.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.service
+package org.meshtastic.core.data.manager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -24,8 +24,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.util.handledLaunch
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
@@ -35,34 +37,33 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshConfigHandler
+class MeshConfigHandlerImpl
@Inject
constructor(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
- private val nodeManager: MeshNodeManager,
-) {
+ private val nodeManager: NodeManager,
+) : MeshConfigHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _localConfig = MutableStateFlow(LocalConfig())
- val localConfig = _localConfig.asStateFlow()
+ override val localConfig = _localConfig.asStateFlow()
private val _moduleConfig = MutableStateFlow(LocalModuleConfig())
- val moduleConfig = _moduleConfig.asStateFlow()
+ override val moduleConfig = _moduleConfig.asStateFlow()
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope)
-
radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope)
}
- fun handleDeviceConfig(config: Config) {
+ override fun handleDeviceConfig(config: Config) {
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
serviceRepository.setConnectionProgress("Device config received")
}
- fun handleModuleConfig(config: ModuleConfig) {
+ override fun handleModuleConfig(config: ModuleConfig) {
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
serviceRepository.setConnectionProgress("Module config received")
@@ -71,13 +72,13 @@ constructor(
}
}
- fun handleChannel(ch: Channel) {
+ override fun handleChannel(channel: Channel) {
// We always want to save channel settings we receive from the radio
- scope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) }
+ scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) }
// Update status message if we have node info, otherwise use a generic one
val mi = nodeManager.getMyNodeInfo()
- val index = ch.index ?: 0
+ val index = channel.index
if (mi != null) {
serviceRepository.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})")
} else {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
similarity index 79%
rename from app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index eeb4882dc..a420793df 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -14,19 +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.service
+package org.meshtastic.core.data.manager
-import android.app.Notification
-import android.content.Context
-import androidx.glance.appwidget.updateAll
-import androidx.work.ExistingWorkPolicy
-import androidx.work.OneTimeWorkRequestBuilder
-import androidx.work.WorkManager
-import androidx.work.workDataOf
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
-import com.geeksville.mesh.widget.LocalStatsWidget
-import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -43,12 +33,25 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
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.PacketRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.prefs.ui.UiPrefs
+import org.meshtastic.core.repository.AppWidgetUpdater
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.MeshLocationManager
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.MeshWorkerManager
+import org.meshtastic.core.repository.MqttManager
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
@@ -56,8 +59,6 @@ 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.MeshServiceNotifications
-import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
@@ -70,27 +71,27 @@ import kotlin.time.DurationUnit
@Suppress("LongParameterList", "TooManyFunctions")
@Singleton
-class MeshConnectionManager
+class MeshConnectionManagerImpl
@Inject
constructor(
- @ApplicationContext private val context: Context,
private val radioInterfaceService: RadioInterfaceService,
- private val connectionStateHolder: ConnectionStateHandler,
- private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val serviceRepository: ServiceRepository,
+ private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
private val uiPrefs: UiPrefs,
private val packetHandler: PacketHandler,
private val nodeRepository: NodeRepository,
private val locationManager: MeshLocationManager,
- private val mqttManager: MeshMqttManager,
- private val historyManager: MeshHistoryManager,
+ private val mqttManager: MqttManager,
+ private val historyManager: HistoryManager,
private val radioConfigRepository: RadioConfigRepository,
- private val commandSender: MeshCommandSender,
- private val nodeManager: MeshNodeManager,
+ private val commandSender: CommandSender,
+ private val nodeManager: NodeManager,
private val analytics: PlatformAnalytics,
private val packetRepository: PacketRepository,
- private val workManager: WorkManager,
-) {
+ private val workerManager: MeshWorkerManager,
+ private val appWidgetUpdater: AppWidgetUpdater,
+) : MeshConnectionManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
@@ -98,18 +99,16 @@ constructor(
private var connectTimeMsec = 0L
@OptIn(FlowPreview::class)
- fun start(scope: CoroutineScope) {
+ override 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)
+ serviceRepository.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
- // Kickstart the widget composition. The widget internally uses collectAsState()
- // and its own sampled StateFlow to drive updates automatically without excessive IPC and recreation.
scope.launch {
try {
- LocalStatsWidget().updateAll(context)
+ appWidgetUpdater.updateAll()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Failed to kickstart LocalStatsWidget" }
}
@@ -154,7 +153,7 @@ constructor(
}
private fun onConnectionChanged(c: ConnectionState) {
- val current = connectionStateHolder.connectionState.value
+ val current = serviceRepository.connectionState.value
if (current == c) return
// If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting)
@@ -171,7 +170,7 @@ constructor(
handshakeTimeout = null
when (c) {
- is ConnectionState.Connecting -> connectionStateHolder.setState(ConnectionState.Connecting)
+ is ConnectionState.Connecting -> serviceRepository.setConnectionState(ConnectionState.Connecting)
is ConnectionState.Connected -> handleConnected()
is ConnectionState.DeviceSleep -> handleDeviceSleep()
is ConnectionState.Disconnected -> handleDisconnected()
@@ -180,8 +179,8 @@ constructor(
private fun handleConnected() {
// The service state remains 'Connecting' until config is fully loaded
- if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
- connectionStateHolder.setState(ConnectionState.Connecting)
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
+ serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
Logger.i { "Starting mesh handshake (Stage 1)" }
@@ -192,12 +191,12 @@ constructor(
handshakeTimeout =
scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
- if (connectionStateHolder.connectionState.value is ConnectionState.Connecting) {
+ if (serviceRepository.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) {
+ if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.e { "Handshake still stalled after retry. Resetting connection." }
onConnectionChanged(ConnectionState.Disconnected)
}
@@ -206,7 +205,7 @@ constructor(
}
private fun handleDeviceSleep() {
- connectionStateHolder.setState(ConnectionState.DeviceSleep)
+ serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
packetHandler.stopPacketQueue()
locationManager.stop()
mqttManager.stop()
@@ -239,7 +238,7 @@ constructor(
}
private fun handleDisconnected() {
- connectionStateHolder.setState(ConnectionState.Disconnected)
+ serviceRepository.setConnectionState(ConnectionState.Disconnected)
packetHandler.stopPacketQueue()
locationManager.stop()
mqttManager.stop()
@@ -254,29 +253,20 @@ constructor(
serviceBroadcasts.broadcastConnection()
}
- fun startConfigOnly() {
+ override fun startConfigOnly() {
packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE))
}
- fun startNodeInfoOnly() {
+ override fun startNodeInfoOnly() {
packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE))
}
- fun onRadioConfigLoaded() {
+ override fun onRadioConfigLoaded() {
scope.handledLaunch {
val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
queuedPackets.forEach { packet ->
try {
- val workRequest =
- OneTimeWorkRequestBuilder()
- .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packet.id))
- .build()
-
- workManager.enqueueUniqueWork(
- "${SendMessageWorker.WORK_NAME_PREFIX}${packet.id}",
- ExistingWorkPolicy.REPLACE,
- workRequest,
- )
+ workerManager.enqueueSendMessage(packet.id)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Failed to enqueue queued packet worker" }
}
@@ -288,7 +278,7 @@ constructor(
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
}
- fun onNodeDbReady() {
+ override fun onNodeDbReady() {
handshakeTimeout?.cancel()
handshakeTimeout = null
@@ -329,14 +319,14 @@ constructor(
)
}
- fun updateTelemetry(telemetry: Telemetry) {
- telemetry.local_stats?.let { nodeRepository.updateLocalStats(it) }
- updateStatusNotification(telemetry)
+ override fun updateTelemetry(t: Telemetry) {
+ t.local_stats?.let { nodeRepository.updateLocalStats(it) }
+ updateStatusNotification(t)
}
- fun updateStatusNotification(telemetry: Telemetry? = null): Notification {
+ override fun updateStatusNotification(telemetry: Telemetry?): Any {
val summary =
- when (connectionStateHolder.connectionState.value) {
+ when (serviceRepository.connectionState.value) {
is ConnectionState.Connected ->
getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
is ConnectionState.Disconnected -> getString(Res.string.disconnected)
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
similarity index 81%
rename from app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 36338d493..e84af354c 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -14,13 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
-import android.util.Log
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
-import com.geeksville.mesh.BuildConfig
-import com.geeksville.mesh.repository.radio.InterfaceId
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -33,25 +30,36 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.PacketRepository
-import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.entity.Packet
-import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.Reaction
+import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
-import org.meshtastic.core.prefs.mesh.MeshPrefs
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.MeshConfigFlowManager
+import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.MeshConnectionManager
+import org.meshtastic.core.repository.MeshDataHandler
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.MessageFilter
+import org.meshtastic.core.repository.NeighborInfoHandler
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.error_duty_cycle
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
-import org.meshtastic.core.service.MeshServiceNotifications
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.core.service.filter.MessageFilterService
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
@@ -70,33 +78,42 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
+/**
+ * Implementation of [MeshDataHandler] that decodes and routes incoming mesh data packets.
+ *
+ * This class handles the complexity of:
+ * 1. Mapping raw [MeshPacket] objects to domain-friendly [DataPacket] objects.
+ * 2. Routing packets to specialized handlers (e.g., Traceroute, NeighborInfo, SFPP).
+ * 3. Managing message history and persistence.
+ * 4. Triggering notifications for various packet types (Text, Waypoints, Battery).
+ * 5. Tracking received telemetry for node updates.
+ */
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "CyclomaticComplexMethod")
@Singleton
-class MeshDataHandler
+class MeshDataHandlerImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
+ private val nodeManager: NodeManager,
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
private val packetRepository: Lazy,
- private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
private val dataMapper: MeshDataMapper,
- private val configHandler: MeshConfigHandler,
- private val configFlowManager: MeshConfigFlowManager,
- private val commandSender: MeshCommandSender,
- private val historyManager: MeshHistoryManager,
- private val meshPrefs: MeshPrefs,
- private val connectionManager: MeshConnectionManager,
- private val tracerouteHandler: MeshTracerouteHandler,
- private val neighborInfoHandler: MeshNeighborInfoHandler,
+ private val configHandler: Lazy,
+ private val configFlowManager: Lazy,
+ private val commandSender: CommandSender,
+ private val historyManager: HistoryManager,
+ private val connectionManager: Lazy,
+ private val tracerouteHandler: TracerouteHandler,
+ private val neighborInfoHandler: NeighborInfoHandler,
private val radioConfigRepository: RadioConfigRepository,
- private val messageFilterService: MessageFilterService,
-) {
+ private val messageFilter: MessageFilter,
+) : MeshDataHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
@@ -108,7 +125,7 @@ constructor(
PortNum.NODE_STATUS_APP.value,
)
- fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) {
+ override fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String?, logInsertJob: Job?) {
val dataPacket = dataMapper.toDataPacket(packet) ?: return
val fromUs = myNodeNum == packet.from
dataPacket.status = MessageStatus.RECEIVED
@@ -221,7 +238,7 @@ constructor(
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
}
- @Suppress("LongMethod")
+ @Suppress("LongMethod", "ReturnCount")
private fun handleStoreForwardPlusPlus(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val sfpp =
@@ -340,20 +357,20 @@ constructor(
val fromNum = packet.from
u.get_module_config_response?.let { config ->
if (fromNum == myNodeNum) {
- configHandler.handleModuleConfig(config)
+ configHandler.get().handleModuleConfig(config)
} else {
config.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
}
}
if (fromNum == myNodeNum) {
- u.get_config_response?.let { configHandler.handleDeviceConfig(it) }
- u.get_channel_response?.let { configHandler.handleChannel(it) }
+ u.get_config_response?.let { configHandler.get().handleDeviceConfig(it) }
+ u.get_channel_response?.let { configHandler.get().handleChannel(it) }
}
u.get_device_metadata_response?.let { metadata ->
if (fromNum == myNodeNum) {
- configFlowManager.handleLocalMetadata(metadata)
+ configFlowManager.get().handleLocalMetadata(metadata)
} else {
nodeManager.insertMetadata(fromNum, metadata)
}
@@ -395,39 +412,43 @@ constructor(
val fromNum = packet.from
val isRemote = (fromNum != myNodeNum)
if (!isRemote) {
- connectionManager.updateTelemetry(t)
+ connectionManager.get().updateTelemetry(t)
}
- nodeManager.updateNodeInfo(fromNum) { nodeEntity ->
+ nodeManager.updateNode(fromNum) { node: Node ->
val metrics = t.device_metrics
val environment = t.environment_metrics
val power = t.power_metrics
+
+ var nextNode = node
when {
metrics != null -> {
- nodeEntity.deviceTelemetry = t
- if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) {
+ nextNode = nextNode.copy(deviceMetrics = metrics)
+ if (fromNum == myNodeNum || (isRemote && node.isFavorite)) {
if (
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
) {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
- serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote)
+ serviceNotifications.showOrUpdateLowBatteryNotification(nextNode, isRemote)
}
} else {
if (batteryPercentCooldowns.containsKey(fromNum)) {
batteryPercentCooldowns.remove(fromNum)
}
- serviceNotifications.cancelLowBatteryNotification(nodeEntity)
+ serviceNotifications.cancelLowBatteryNotification(nextNode)
}
}
}
- environment != null -> nodeEntity.environmentTelemetry = t
- power != null -> nodeEntity.powerTelemetry = t
+ environment != null -> nextNode = nextNode.copy(environmentMetrics = environment)
+ power != null -> nextNode = nextNode.copy(powerMetrics = power)
}
+ nextNode
}
}
+ @Suppress("ReturnCount")
private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
val isRemote = (fromNum != myNodeNum)
var shouldDisplay = false
@@ -475,30 +496,26 @@ constructor(
private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) {
scope.handledLaunch {
val isAck = routingError == Routing.Error.NONE.value
- val p = packetRepository.get().getPacketById(requestId)
+ val p = packetRepository.get().getPacketByPacketId(requestId)
val reaction = packetRepository.get().getReactionByPacketId(requestId)
@Suppress("MaxLineLength")
Logger.d {
- val statusInfo = "status=${p?.data?.status ?: reaction?.status}"
+ val statusInfo = "status=${p?.status ?: reaction?.status}"
"[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
- "packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} $statusInfo"
+ "packetId=${p?.id ?: reaction?.packetId} dataId=${p?.id} $statusInfo"
}
val m =
when {
- isAck && (fromId == p?.data?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED
+ isAck && (fromId == p?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED
isAck -> MessageStatus.DELIVERED
else -> MessageStatus.ERROR
}
- if (p != null && p.data.status != MessageStatus.RECEIVED) {
- p.data.status = m
- p.routingError = routingError
- if (isAck) {
- p.data.relays += 1
- }
- p.data.relayNode = relayNode
- packetRepository.get().update(p)
+ if (p != null && p.status != MessageStatus.RECEIVED) {
+ val updatedPacket =
+ p.copy(status = m, relays = if (isAck) p.relays + 1 else p.relays, relayNode = relayNode)
+ packetRepository.get().update(updatedPacket)
}
reaction?.let { r ->
@@ -517,11 +534,11 @@ constructor(
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
- val transport = currentTransport()
+ // For now, we don't have meshPrefs in commonMain, so we use a simplified transport check or abstract it.
+ // In the original, it was used for logging.
val h = s.history
val lastRequest = h?.last_request ?: 0
- val baseContext = "transport=$transport from=${dataPacket.from}"
- historyLog { "rxStoreForward $baseContext lastRequest=$lastRequest" }
+ Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
when {
s.stats != null -> {
val text = s.stats.toString()
@@ -533,10 +550,6 @@ constructor(
rememberDataPacket(u, myNodeNum)
}
h != null -> {
- @Suppress("MaxLineLength")
- historyLog(Log.DEBUG) {
- "routerHistory $baseContext messages=${h.history_messages} window=${h.window} lastReq=${h.last_request}"
- }
val text =
"Total messages: ${h.history_messages}\n" +
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
@@ -547,20 +560,17 @@ constructor(
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
rememberDataPacket(u, myNodeNum)
- historyManager.updateStoreForwardLastRequest("router_history", h.last_request, transport)
+ // historyManager call remains same
+ historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
}
s.heartbeat != null -> {
val hb = s.heartbeat!!
- historyLog { "rxHeartbeat $baseContext period=${hb.period} secondary=${hb.secondary}" }
+ Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
}
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
}
- @Suppress("MaxLineLength")
- historyLog(Log.DEBUG) {
- "rxText $baseContext id=${dataPacket.id} ts=${dataPacket.time} to=${dataPacket.to} decision=remember"
- }
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
rememberDataPacket(u, myNodeNum)
}
@@ -568,7 +578,7 @@ constructor(
}
}
- fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) {
+ override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
if (dataPacket.dataType !in rememberDataType) return
val fromLocal =
dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum)
@@ -594,25 +604,16 @@ constructor(
// Check if message should be filtered
val isFiltered = shouldFilterMessage(dataPacket, contactKey)
- val packetToSave =
- Packet(
- uuid = 0L,
- myNodeNum = myNodeNum,
- packetId = dataPacket.id,
- port_num = dataPacket.dataType,
- contact_key = contactKey,
- received_time = nowMillis,
- read = fromLocal || isFiltered,
- data = dataPacket,
- snr = dataPacket.snr,
- rssi = dataPacket.rssi,
- hopsAway = dataPacket.hopsAway,
- filtered = isFiltered,
- )
-
- insert(packetToSave)
+ insert(
+ dataPacket,
+ myNodeNum,
+ contactKey,
+ nowMillis,
+ read = fromLocal || isFiltered,
+ filtered = isFiltered,
+ )
if (!isFiltered) {
- handlePacketNotification(packetToSave, dataPacket, contactKey, updateNotification)
+ handlePacketNotification(dataPacket, contactKey, updateNotification)
}
}
}
@@ -625,11 +626,10 @@ constructor(
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled
- return messageFilterService.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
+ return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
}
private suspend fun handlePacketNotification(
- packet: Packet,
dataPacket: DataPacket,
contactKey: String,
updateNotification: Boolean,
@@ -637,7 +637,7 @@ constructor(
val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
- if (packet.port_num == PortNum.ALERT_APP.value && !isSilent) {
+ if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
serviceNotifications.showAlertNotification(
contactKey,
getSenderName(dataPacket),
@@ -696,13 +696,14 @@ constructor(
val decoded = packet.decoded ?: return@handledLaunch
val emoji = decoded.payload.toByteArray().decodeToString()
val fromId = nodeManager.toNodeID(packet.from)
- val toId = nodeManager.toNodeID(packet.to)
+
+ val fromNode = nodeManager.nodeDBbyNodeNum[packet.from] ?: Node(num = packet.from)
+ val toNode = nodeManager.nodeDBbyNodeNum[packet.to] ?: Node(num = packet.to)
val reaction =
- ReactionEntity(
- myNodeNum = nodeManager.myNodeNum ?: 0,
+ Reaction(
replyId = decoded.reply_id,
- userId = fromId,
+ user = fromNode.user,
emoji = emoji,
timestamp = nowMillis,
snr = packet.rx_snr,
@@ -715,7 +716,7 @@ constructor(
},
packetId = packet.id,
status = MessageStatus.RECEIVED,
- to = toId,
+ to = toNode.user.id,
channel = packet.channel,
)
@@ -729,25 +730,25 @@ constructor(
return@handledLaunch
}
- packetRepository.get().insertReaction(reaction)
+ packetRepository.get().insertReaction(reaction, nodeManager.myNodeNum ?: 0)
// Find the original packet to get the contactKey
- packetRepository.get().getPacketByPacketId(decoded.reply_id)?.let { original ->
+ packetRepository.get().getPacketByPacketId(decoded.reply_id)?.let { originalPacket ->
// Skip notification if the original message was filtered
- if (original.packet.filtered) return@let
-
- val contactKey = original.packet.contact_key
+ val targetId =
+ if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from
+ val contactKey = "${originalPacket.channel}$targetId"
val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (!isSilent) {
val channelName =
- if (original.packet.data.to == DataPacket.ID_BROADCAST) {
+ if (originalPacket.to == DataPacket.ID_BROADCAST) {
radioConfigRepository.channelSetFlow
.first()
.settings
- .getOrNull(original.packet.data.channel)
+ .getOrNull(originalPacket.channel)
?.name
} else {
null
@@ -756,7 +757,7 @@ constructor(
contactKey,
getSenderName(dataMapper.toDataPacket(packet)!!),
emoji,
- original.packet.data.to == DataPacket.ID_BROADCAST,
+ originalPacket.to == DataPacket.ID_BROADCAST,
channelName,
isSilent,
)
@@ -764,33 +765,6 @@ constructor(
}
}
- private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) {
- InterfaceId.BLUETOOTH.id -> "BLE"
- InterfaceId.TCP.id -> "TCP"
- InterfaceId.SERIAL.id -> "Serial"
- InterfaceId.MOCK.id -> "Mock"
- InterfaceId.NOP.id -> "NOP"
- else -> "Unknown"
- }
-
- private inline fun historyLog(
- priority: Int = Log.INFO,
- throwable: Throwable? = null,
- crossinline message: () -> String,
- ) {
- if (!BuildConfig.DEBUG) return
- val logger = Logger.withTag("HistoryReplay")
- val msg = message()
- when (priority) {
- Log.VERBOSE -> logger.v(throwable) { msg }
- Log.DEBUG -> logger.d(throwable) { msg }
- Log.INFO -> logger.i(throwable) { msg }
- Log.WARN -> logger.w(throwable) { msg }
- Log.ERROR -> logger.e(throwable) { msg }
- else -> logger.i(throwable) { msg }
- }
- }
-
companion object {
private const val HOPS_AWAY_UNAVAILABLE = -1
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
similarity index 75%
rename from app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index 7ed7980c3..1c19c8f31 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -14,11 +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.service
+package org.meshtastic.core.data.manager
-import android.util.Log
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.BuildConfig
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -31,8 +29,13 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.isLora
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.FromRadioPacketHandler
+import org.meshtastic.core.repository.MeshMessageProcessor
+import org.meshtastic.core.repository.MeshRouter
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
import org.meshtastic.proto.MeshPacket
@@ -44,17 +47,18 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlin.uuid.Uuid
+/** Implementation of [MeshMessageProcessor] that handles raw radio messages and prepares mesh packets for routing. */
@Suppress("TooManyFunctions")
@Singleton
-class MeshMessageProcessor
+class MeshMessageProcessorImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
+ private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val meshLogRepository: Lazy,
- private val router: MeshRouter,
+ private val router: Lazy,
private val fromRadioDispatcher: FromRadioPacketHandler,
-) {
+) : MeshMessageProcessor {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val logUuidByPacketId = ConcurrentHashMap()
private val logInsertJobByPacketId = ConcurrentHashMap()
@@ -62,11 +66,11 @@ constructor(
private val earlyReceivedPackets = ArrayDeque()
private val maxEarlyPacketBuffer = 10240
- fun clearEarlyPackets() {
+ override fun clearEarlyPackets() {
synchronized(earlyReceivedPackets) { earlyReceivedPackets.clear() }
}
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
nodeManager.isNodeDbReady
.onEach { ready ->
@@ -77,7 +81,7 @@ constructor(
.launchIn(scope)
}
- fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) {
+ override fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) {
runCatching { FromRadio.ADAPTER.decode(bytes) }
.onSuccess { proto -> processFromRadio(proto, myNodeNum) }
.onFailure { primaryException ->
@@ -134,7 +138,7 @@ constructor(
)
}
- fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
+ override fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
val rxTime =
if (packet.rx_time == 0) {
nowSeconds.toInt()
@@ -149,21 +153,9 @@ constructor(
synchronized(earlyReceivedPackets) {
val queueSize = earlyReceivedPackets.size
if (queueSize >= maxEarlyPacketBuffer) {
- val dropped = earlyReceivedPackets.removeFirst()
- historyLog(Log.WARN) {
- val portLabel =
- dropped.decoded?.portnum?.name ?: dropped.decoded?.portnum?.value?.toString() ?: "unknown"
- "dropEarlyPacket bufferFull size=$queueSize id=${dropped.id} port=$portLabel"
- }
+ earlyReceivedPackets.removeFirst()
}
earlyReceivedPackets.addLast(preparedPacket)
- val portLabel =
- preparedPacket.decoded?.portnum?.name
- ?: preparedPacket.decoded?.portnum?.value?.toString()
- ?: "unknown"
- historyLog {
- "queueEarlyPacket size=${earlyReceivedPackets.size} id=${preparedPacket.id} port=$portLabel"
- }
}
}
}
@@ -176,11 +168,12 @@ constructor(
earlyReceivedPackets.clear()
list
}
- historyLog { "replayEarlyPackets reason=$reason count=${packets.size}" }
+ Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" }
val myNodeNum = nodeManager.myNodeNum
packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
}
+ @Suppress("LongMethod")
private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
val decoded = packet.decoded ?: return
val log =
@@ -202,22 +195,24 @@ constructor(
myNodeNum?.let { myNum ->
val from = packet.from
val isOtherNode = myNum != from
- nodeManager.updateNodeInfo(myNum, withBroadcast = isOtherNode) { it.lastHeard = nowSeconds.toInt() }
- nodeManager.updateNodeInfo(from, withBroadcast = false, channel = packet.channel) {
- it.lastHeard = packet.rx_time
- it.viaMqtt = packet.via_mqtt == true
- it.lastTransport = packet.transport_mechanism.value
-
+ nodeManager.updateNode(myNum, withBroadcast = isOtherNode) { node: Node ->
+ node.copy(lastHeard = nowSeconds.toInt())
+ }
+ nodeManager.updateNode(from, withBroadcast = false, channel = packet.channel) { node: Node ->
+ val viaMqtt = packet.via_mqtt == true
val isDirect = packet.hop_start == packet.hop_limit
- if (isDirect && packet.isLora() && !it.viaMqtt) {
- it.snr = packet.rx_snr
- it.rssi = packet.rx_rssi
+
+ var snr = node.snr
+ var rssi = node.rssi
+ if (isDirect && packet.isLora() && !viaMqtt) {
+ snr = packet.rx_snr
+ rssi = packet.rx_rssi
}
- it.hopsAway =
+ val hopsAway =
if (decoded.portnum == PortNum.RANGE_TEST_APP) {
0
- } else if (it.viaMqtt) {
+ } else if (viaMqtt) {
-1
} else if (packet.hop_start == 0 && (decoded.bitfield ?: 0) == 0) {
-1
@@ -226,10 +221,19 @@ constructor(
} else {
packet.hop_start - packet.hop_limit
}
+
+ node.copy(
+ lastHeard = packet.rx_time,
+ viaMqtt = viaMqtt,
+ lastTransport = packet.transport_mechanism.value,
+ snr = snr,
+ rssi = rssi,
+ hopsAway = hopsAway,
+ )
}
try {
- router.dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob)
+ router.get().dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob)
} finally {
logUuidByPacketId.remove(packet.id)
logInsertJobByPacketId.remove(packet.id)
@@ -239,24 +243,6 @@ constructor(
private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.get().insert(log) }
- private inline fun historyLog(
- priority: Int = Log.INFO,
- throwable: Throwable? = null,
- crossinline message: () -> String,
- ) {
- if (!BuildConfig.DEBUG) return
- val logger = Logger.withTag("HistoryReplay")
- val msg = message()
- when (priority) {
- Log.VERBOSE -> logger.v(throwable) { msg }
- Log.DEBUG -> logger.d(throwable) { msg }
- Log.INFO -> logger.i(throwable) { msg }
- Log.WARN -> logger.w(throwable) { msg }
- Log.ERROR -> logger.e(throwable) { msg }
- else -> logger.i(throwable) { msg }
- }
- }
-
private fun ByteArray.toHexString(): String =
this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) }
}
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt
new file mode 100644
index 000000000..b079b1d86
--- /dev/null
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.data.manager
+
+import dagger.Lazy
+import kotlinx.coroutines.CoroutineScope
+import org.meshtastic.core.repository.MeshActionHandler
+import org.meshtastic.core.repository.MeshConfigFlowManager
+import org.meshtastic.core.repository.MeshConfigHandler
+import org.meshtastic.core.repository.MeshDataHandler
+import org.meshtastic.core.repository.MeshRouter
+import org.meshtastic.core.repository.MqttManager
+import org.meshtastic.core.repository.NeighborInfoHandler
+import org.meshtastic.core.repository.TracerouteHandler
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */
+@Suppress("LongParameterList")
+@Singleton
+class MeshRouterImpl
+@Inject
+constructor(
+ private val dataHandlerLazy: Lazy,
+ private val configHandlerLazy: Lazy,
+ private val tracerouteHandlerLazy: Lazy,
+ private val neighborInfoHandlerLazy: Lazy,
+ private val configFlowManagerLazy: Lazy,
+ private val mqttManagerLazy: Lazy,
+ private val actionHandlerLazy: Lazy,
+) : MeshRouter {
+ override val dataHandler: MeshDataHandler
+ get() = dataHandlerLazy.get()
+
+ override val configHandler: MeshConfigHandler
+ get() = configHandlerLazy.get()
+
+ override val tracerouteHandler: TracerouteHandler
+ get() = tracerouteHandlerLazy.get()
+
+ override val neighborInfoHandler: NeighborInfoHandler
+ get() = neighborInfoHandlerLazy.get()
+
+ override val configFlowManager: MeshConfigFlowManager
+ get() = configFlowManagerLazy.get()
+
+ override val mqttManager: MqttManager
+ get() = mqttManagerLazy.get()
+
+ override val actionHandler: MeshActionHandler
+ get() = actionHandlerLazy.get()
+
+ override fun start(scope: CoroutineScope) {
+ dataHandler.start(scope)
+ configHandler.start(scope)
+ tracerouteHandler.start(scope)
+ neighborInfoHandler.start(scope)
+ configFlowManager.start(scope)
+ actionHandler.start(scope)
+ }
+}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt
similarity index 69%
rename from core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt
index bb8a773aa..906e615ae 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/filter/MessageFilterService.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MessageFilterImpl.kt
@@ -14,34 +14,25 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.service.filter
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import org.meshtastic.core.prefs.filter.FilterPrefs
+import org.meshtastic.core.repository.MessageFilter
import java.util.regex.PatternSyntaxException
import javax.inject.Inject
import javax.inject.Singleton
-/**
- * Service for filtering messages based on user-configured filter words. Supports both plain text word matching and
- * regex patterns.
- */
+/** Implementation of [MessageFilter] that uses regex and plain text matching. */
@Singleton
-class MessageFilterService @Inject constructor(private val filterPrefs: FilterPrefs) {
+class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs) : MessageFilter {
private var compiledPatterns: List = emptyList()
init {
rebuildPatterns()
}
- /**
- * Determines if a message should be filtered based on the configured filter words.
- *
- * @param message The message text to check.
- * @param isFilteringDisabled Whether filtering is disabled for this contact.
- * @return true if the message should be filtered, false otherwise.
- */
- fun shouldFilter(message: String, isFilteringDisabled: Boolean = false): Boolean {
+ override fun shouldFilter(message: String, isFilteringDisabled: Boolean): Boolean {
if (!filterPrefs.filterEnabled || compiledPatterns.isEmpty() || isFilteringDisabled) {
return false
}
@@ -49,11 +40,7 @@ class MessageFilterService @Inject constructor(private val filterPrefs: FilterPr
return compiledPatterns.any { it.containsMatchIn(textToCheck) }
}
- /**
- * Rebuilds the compiled regex patterns from the current filter words. Should be called whenever the filter words
- * are updated.
- */
- fun rebuildPatterns() {
+ override fun rebuildPatterns() {
compiledPatterns =
filterPrefs.filterWords.mapNotNull { word ->
try {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
similarity index 84%
rename from app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
index 314b7c99c..7684ebd20 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshMqttManager.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
@@ -14,11 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
-import com.geeksville.mesh.repository.network.MQTTRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -26,24 +25,27 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.network.repository.MQTTRepository
+import org.meshtastic.core.repository.MqttManager
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshMqttManager
+class MqttManagerImpl
@Inject
constructor(
private val mqttRepository: MQTTRepository,
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
-) {
+) : MqttManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var mqttMessageFlow: Job? = null
- fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {
+ override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {
this.scope = scope
if (mqttMessageFlow?.isActive == true) return
if (enabled && proxyToClientEnabled) {
@@ -60,7 +62,7 @@ constructor(
}
}
- fun stop() {
+ override fun stop() {
if (mqttMessageFlow?.isActive == true) {
Logger.i { "Stopping MqttClientProxy" }
mqttMessageFlow?.cancel()
@@ -68,7 +70,7 @@ constructor(
}
}
- fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
+ override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
val topic = message.topic ?: ""
Logger.d { "[mqttClientProxyMessage] $topic" }
val retained = message.retained == true
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
similarity index 77%
rename from app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
index 3574bf6e1..df19abacf 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
@@ -14,17 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.geeksville.mesh.service
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.getString
-import org.meshtastic.core.resources.unknown_username
-import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.NeighborInfoHandler
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import java.util.Locale
@@ -32,21 +33,21 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshNeighborInfoHandler
+class NeighborInfoHandlerImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
+ private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
- private val commandSender: MeshCommandSender,
- private val serviceBroadcasts: MeshServiceBroadcasts,
-) {
+ private val commandSender: CommandSender,
+ private val serviceBroadcasts: ServiceBroadcasts,
+) : NeighborInfoHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
- fun handleNeighborInfo(packet: MeshPacket) {
+ override fun handleNeighborInfo(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val ni = NeighborInfo.ADAPTER.decode(payload)
@@ -58,7 +59,7 @@ constructor(
}
// Update Node DB
- nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it.toNodeInfo()) }
+ nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) }
// Format for UI response
val requestId = packet.decoded?.request_id ?: 0
@@ -67,11 +68,11 @@ constructor(
val neighbors =
ni.neighbors.joinToString("\n") { n ->
val node = nodeManager.nodeDBbyNodeNum[n.node_id]
- val name = node?.let { "${it.longName} (${it.shortName})" } ?: getString(Res.string.unknown_username)
+ val name = node?.let { "${it.user.long_name} (${it.user.short_name})" } ?: "Unknown"
"• $name (SNR: ${n.snr})"
}
- val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[from]?.longName ?: "Unknown"}:\n$neighbors"
+ val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[from]?.user?.long_name ?: "Unknown"}:\n$neighbors"
val responseText =
if (start != null) {
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
new file mode 100644
index 000000000..e9172809b
--- /dev/null
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
@@ -0,0 +1,316 @@
+/*
+ * 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.data.manager
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import okio.ByteString
+import org.meshtastic.core.common.util.handledLaunch
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.DeviceMetrics
+import org.meshtastic.core.model.EnvironmentMetrics
+import org.meshtastic.core.model.MeshUser
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.NodeInfo
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.repository.MeshServiceNotifications
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.HardwareModel
+import org.meshtastic.proto.Paxcount
+import org.meshtastic.proto.StatusMessage
+import org.meshtastic.proto.Telemetry
+import org.meshtastic.proto.User
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
+import org.meshtastic.proto.Position as ProtoPosition
+
+/**
+ * Implementation of [NodeManager] that maintains an in-memory database of the mesh.
+ *
+ * This component acts as the "brain" for node-related data during a connection session. It manages:
+ * 1. In-memory maps for fast node lookup by number or ID.
+ * 2. Synchronization of node data between the radio and the persistent database.
+ * 3. Processing of incoming node-related packets (User, Position, Telemetry).
+ * 4. Broadcasting changes to the rest of the application.
+ */
+@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
+@Singleton
+class NodeManagerImpl
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ private val serviceBroadcasts: ServiceBroadcasts,
+ private val serviceNotifications: MeshServiceNotifications,
+) : NodeManager {
+ private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ override val nodeDBbyNodeNum = ConcurrentHashMap()
+ override val nodeDBbyID = ConcurrentHashMap()
+
+ override val isNodeDbReady = MutableStateFlow(false)
+ override val allowNodeDbWrites = MutableStateFlow(false)
+
+ override fun setNodeDbReady(ready: Boolean) {
+ isNodeDbReady.value = ready
+ }
+
+ override fun setAllowNodeDbWrites(allowed: Boolean) {
+ allowNodeDbWrites.value = allowed
+ }
+
+ override var myNodeNum: Int? = null
+
+ override fun start(scope: CoroutineScope) {
+ this.scope = scope
+ }
+
+ companion object {
+ private const val TIME_MS_TO_S = 1000L
+ }
+
+ override fun loadCachedNodeDB() {
+ scope.handledLaunch {
+ val nodes = nodeRepository.nodeDBbyNum.first()
+ nodeDBbyNodeNum.putAll(nodes)
+ nodes.values.forEach { nodeDBbyID[it.user.id] = it }
+ myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
+ }
+ }
+
+ override fun clear() {
+ nodeDBbyNodeNum.clear()
+ nodeDBbyID.clear()
+ isNodeDbReady.value = false
+ allowNodeDbWrites.value = false
+ myNodeNum = null
+ }
+
+ override fun getMyNodeInfo(): MyNodeInfo? {
+ val mi = nodeRepository.myNodeInfo.value ?: return null
+ val myNode = nodeDBbyNodeNum[mi.myNodeNum]
+ return MyNodeInfo(
+ myNodeNum = mi.myNodeNum,
+ hasGPS = (myNode?.position?.latitude_i ?: 0) != 0,
+ model = mi.model ?: myNode?.user?.hw_model?.name,
+ firmwareVersion = mi.firmwareVersion,
+ couldUpdate = mi.couldUpdate,
+ shouldUpdate = mi.shouldUpdate,
+ currentPacketId = mi.currentPacketId,
+ messageTimeoutMsec = mi.messageTimeoutMsec,
+ minAppVersion = mi.minAppVersion,
+ maxChannels = mi.maxChannels,
+ hasWifi = mi.hasWifi,
+ channelUtilization = 0f,
+ airUtilTx = 0f,
+ deviceId = mi.deviceId ?: myNode?.user?.id,
+ )
+ }
+
+ override fun getMyId(): String {
+ val num = myNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
+ return nodeDBbyNodeNum[num]?.user?.id ?: ""
+ }
+
+ override fun getNodes(): List = nodeDBbyNodeNum.values.map { it.toNodeInfo() }
+
+ override fun removeByNodenum(nodeNum: Int) {
+ nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) }
+ }
+
+ fun getOrCreateNode(n: Int, channel: Int = 0): Node = nodeDBbyNodeNum.getOrPut(n) {
+ val userId = DataPacket.nodeNumToDefaultId(n)
+ val defaultUser =
+ User(
+ id = userId,
+ long_name = "Meshtastic ${userId.takeLast(n = 4)}",
+ short_name = userId.takeLast(n = 4),
+ hw_model = HardwareModel.UNSET,
+ )
+
+ Node(num = n, user = defaultUser, channel = channel)
+ }
+
+ override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) {
+ val current = nodeDBbyNodeNum[nodeNum] ?: getOrCreateNode(nodeNum, channel)
+ val next = transform(current)
+ nodeDBbyNodeNum[nodeNum] = next
+ if (next.user.id.isNotEmpty()) {
+ nodeDBbyID[next.user.id] = next
+ }
+
+ if (next.user.id.isNotEmpty() && isNodeDbReady.value) {
+ scope.handledLaunch { nodeRepository.upsert(next) }
+ }
+
+ if (withBroadcast) {
+ serviceBroadcasts.broadcastNodeChange(next)
+ }
+ }
+
+ override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) {
+ updateNode(fromNum) { node ->
+ val newNode = (node.isUnknownUser && p.hw_model != HardwareModel.UNSET)
+ val shouldPreserve = shouldPreserveExistingUser(node.user, p)
+
+ val next =
+ if (shouldPreserve) {
+ node.copy(channel = channel, manuallyVerified = manuallyVerified)
+ } else {
+ val keyMatch = !node.hasPKC || node.user.public_key == p.public_key
+ val newUser = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY)
+ node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified)
+ }
+ if (newNode && !shouldPreserve) {
+ serviceNotifications.showNewNodeSeenNotification(next)
+ }
+ next
+ }
+ }
+
+ override fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) {
+ if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) {
+ Logger.d { "Ignoring nop position update for the local node" }
+ } else {
+ updateNode(fromNum) { node ->
+ node.copy(position = p.copy(time = if (p.time != 0) p.time else (defaultTime / TIME_MS_TO_S).toInt()))
+ }
+ }
+ }
+
+ override fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) {
+ updateNode(fromNum) { node ->
+ when {
+ telemetry.device_metrics != null -> node.copy(deviceMetrics = telemetry.device_metrics!!)
+ telemetry.environment_metrics != null -> node.copy(environmentMetrics = telemetry.environment_metrics!!)
+ telemetry.power_metrics != null -> node.copy(powerMetrics = telemetry.power_metrics!!)
+ else -> node
+ }
+ }
+ }
+
+ override fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) {
+ updateNode(fromNum) { it.copy(paxcounter = p) }
+ }
+
+ override fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) {
+ updateNodeStatus(fromNum, s.status)
+ }
+
+ override fun updateNodeStatus(nodeNum: Int, status: String?) {
+ updateNode(nodeNum) { it.copy(nodeStatus = status?.takeIf { s -> s.isNotEmpty() }) }
+ }
+
+ override fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean) {
+ updateNode(info.num, withBroadcast = withBroadcast) { node ->
+ var next = node
+ val user = info.user
+ if (user != null) {
+ if (shouldPreserveExistingUser(node.user, user)) {
+ // keep existing names
+ } else {
+ var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it }
+ if (info.via_mqtt) {
+ newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
+ }
+ next = next.copy(user = newUser)
+ }
+ }
+ val position = info.position
+ if (position != null) {
+ next = next.copy(position = position)
+ }
+ next =
+ next.copy(
+ lastHeard = info.last_heard,
+ deviceMetrics = info.device_metrics ?: next.deviceMetrics,
+ channel = info.channel,
+ viaMqtt = info.via_mqtt,
+ hopsAway = info.hops_away ?: -1,
+ isFavorite = info.is_favorite,
+ isIgnored = info.is_ignored,
+ isMuted = info.is_muted,
+ )
+ next
+ }
+ }
+
+ override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {
+ scope.handledLaunch { nodeRepository.insertMetadata(nodeNum, metadata) }
+ }
+
+ private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean {
+ val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
+ val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET
+ val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET
+ return hasExistingUser && isDefaultName && isDefaultHwModel
+ }
+
+ override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
+ DataPacket.ID_BROADCAST
+ } else {
+ nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
+ }
+
+ private fun Node.toNodeInfo(): NodeInfo = NodeInfo(
+ num = num,
+ user =
+ MeshUser(
+ id = user.id,
+ longName = user.long_name,
+ shortName = user.short_name,
+ hwModel = user.hw_model,
+ role = user.role.value,
+ ),
+ position =
+ Position(
+ latitude = latitude,
+ longitude = longitude,
+ altitude = position.altitude ?: 0,
+ time = position.time,
+ satellitesInView = position.sats_in_view ?: 0,
+ groundSpeed = position.ground_speed ?: 0,
+ groundTrack = position.ground_track ?: 0,
+ precisionBits = position.precision_bits ?: 0,
+ )
+ .takeIf { latitude != 0.0 || longitude != 0.0 },
+ snr = snr,
+ rssi = rssi,
+ lastHeard = lastHeard,
+ deviceMetrics =
+ DeviceMetrics(
+ batteryLevel = deviceMetrics.battery_level ?: 0,
+ voltage = deviceMetrics.voltage ?: 0f,
+ channelUtilization = deviceMetrics.channel_utilization ?: 0f,
+ airUtilTx = deviceMetrics.air_util_tx ?: 0f,
+ uptimeSeconds = deviceMetrics.uptime_seconds ?: 0,
+ ),
+ channel = channel,
+ environmentMetrics = EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0),
+ hopsAway = hopsAway,
+ nodeStatus = nodeStatus,
+ )
+}
diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
similarity index 77%
rename from app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
index d85edd7ad..a29cfed98 100644
--- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
@@ -14,10 +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.service
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
-import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.Lazy
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -30,13 +29,18 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.MeshLogRepository
-import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
@@ -51,18 +55,18 @@ import kotlin.uuid.Uuid
@Suppress("TooManyFunctions")
@Singleton
-class PacketHandler
+class PacketHandlerImpl
@Inject
constructor(
private val packetRepository: Lazy,
- private val serviceBroadcasts: MeshServiceBroadcasts,
+ private val serviceBroadcasts: ServiceBroadcasts,
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: Lazy,
- private val connectionStateHolder: ConnectionStateHandler,
-) {
+ private val serviceRepository: ServiceRepository,
+) : PacketHandler {
companion object {
- private val TIMEOUT = 5.seconds // Increased from 250ms to be more tolerant
+ private val TIMEOUT = 5.seconds
}
private var queueJob: Job? = null
@@ -71,15 +75,11 @@ constructor(
private val queuedPackets = ConcurrentLinkedQueue()
private val queueResponse = ConcurrentHashMap>()
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
- /**
- * Send a command/packet to our radio. But cope with the possibility that we might start up before we are fully
- * bound to the RadioInterfaceService
- */
- fun sendToRadio(p: ToRadio) {
+ override fun sendToRadio(p: ToRadio) {
Logger.d { "Sending to radio ${p.toPIIString()}" }
val b = p.encode()
@@ -94,7 +94,7 @@ constructor(
message_type = "Packet",
received_date = nowMillis,
raw_message = packet.toString(),
- fromNum = MeshLog.NODE_NUM_LOCAL, // Outgoing packets are always from the local node
+ fromNum = MeshLog.NODE_NUM_LOCAL,
portNum = packet.decoded?.portnum?.value ?: 0,
fromRadio = FromRadio(packet = packet),
)
@@ -102,16 +102,12 @@ constructor(
}
}
- /**
- * Send a mesh packet to the radio, if the radio is not currently connected this function will throw
- * NotConnectedException
- */
- fun sendToRadio(packet: MeshPacket) {
+ override fun sendToRadio(packet: MeshPacket) {
queuedPackets.add(packet)
startPacketQueue()
}
- fun stopPacketQueue() {
+ override fun stopPacketQueue() {
if (queueJob?.isActive == true) {
Logger.i { "Stopping packet queueJob" }
queueJob?.cancel()
@@ -122,33 +118,30 @@ constructor(
}
}
- fun handleQueueStatus(queueStatus: QueueStatus) {
+ override fun handleQueueStatus(queueStatus: QueueStatus) {
Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" }
val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, mesh_packet_id) }
- if (success && isFull) return // Queue is full, wait for free != 0
+ if (success && isFull) return
if (requestId != 0) {
queueResponse.remove(requestId)?.complete(success)
} else {
- // This is slightly suboptimal but matches legacy behavior for packets without IDs
queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success)
}
}
- fun removeResponse(dataRequestId: Int, complete: Boolean) {
+ override fun removeResponse(dataRequestId: Int, complete: Boolean) {
queueResponse.remove(dataRequestId)?.complete(complete)
}
- @Suppress("TooGenericExceptionCaught", "SwallowedException")
private fun startPacketQueue() {
if (queueJob?.isActive == true) return
queueJob =
scope.handledLaunch {
Logger.d { "packet queueJob started" }
- while (connectionStateHolder.connectionState.value == ConnectionState.Connected) {
- // take the first packet from the queue head
+ while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queuedPackets.poll() ?: break
+ @Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
- // send packet to the radio and wait for response
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
val success = withTimeout(TIMEOUT) { response.await() }
@@ -164,7 +157,6 @@ constructor(
}
}
- /** Change the status on a DataPacket and update watchers */
private fun changeStatus(packetId: Int, m: MessageStatus) = scope.handledLaunch {
if (packetId != 0) {
getDataPacketById(packetId)?.let { p ->
@@ -175,11 +167,10 @@ constructor(
}
}
- @Suppress("MagicNumber")
private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1.seconds) {
var dataPacket: DataPacket? = null
while (dataPacket == null) {
- dataPacket = packetRepository.get().getPacketById(packetId)?.data
+ dataPacket = packetRepository.get().getPacketById(packetId)
if (dataPacket == null) delay(100.milliseconds)
}
dataPacket
@@ -187,17 +178,14 @@ constructor(
@Suppress("TooGenericExceptionCaught")
private fun sendPacket(packet: MeshPacket): CompletableDeferred {
- // send the packet to the radio and return a CompletableDeferred that will be completed with
- // the result
val deferred = CompletableDeferred()
queueResponse[packet.id] = deferred
try {
- if (connectionStateHolder.connectionState.value != ConnectionState.Connected) {
+ if (serviceRepository.connectionState.value != ConnectionState.Connected) {
throw RadioNotConnectedException()
}
sendToRadio(ToRadio(packet = packet))
} catch (ex: RadioNotConnectedException) {
- // Expected when radio is not connected, log as warning to avoid Crashlytics noise
Logger.w(ex) { "sendToRadio skipped: Not connected to radio" }
deferred.complete(false)
} catch (ex: Exception) {
@@ -209,8 +197,6 @@ constructor(
private fun insertMeshLog(packetToSave: MeshLog) {
scope.handledLaunch {
- // Do not log, because might contain PII
-
Logger.d {
"insert: ${packetToSave.message_type} = " +
"${packetToSave.raw_message.toOneLineString()} from=${packetToSave.fromNum}"
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
similarity index 74%
rename from app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
index 0ca3e3947..2524e8301 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.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.service
+package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
@@ -22,48 +22,47 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.getString
-import org.meshtastic.core.resources.traceroute_duration
-import org.meshtastic.core.resources.traceroute_route_back_to_us
-import org.meshtastic.core.resources.traceroute_route_towards_dest
-import org.meshtastic.core.resources.unknown_username
-import org.meshtastic.core.service.ServiceRepository
-import org.meshtastic.core.service.TracerouteResponse
+import org.meshtastic.core.model.service.TracerouteResponse
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.MeshPacket
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
-class MeshTracerouteHandler
+class TracerouteHandlerImpl
@Inject
constructor(
- private val nodeManager: MeshNodeManager,
+ private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val nodeRepository: NodeRepository,
- private val commandSender: MeshCommandSender,
-) {
+ private val commandSender: CommandSender,
+) : TracerouteHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
- fun start(scope: CoroutineScope) {
+ override fun start(scope: CoroutineScope) {
this.scope = scope
}
- fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) {
+ override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) {
val full =
packet.getFullTracerouteResponse(
getUser = { num ->
- nodeManager.nodeDBbyNodeNum[num]?.let { "${it.longName} (${it.shortName})" }
- ?: getString(Res.string.unknown_username)
+ nodeManager.nodeDBbyNodeNum[num]?.let { node: Node ->
+ "${node.user.long_name} (${node.user.short_name})"
+ } ?: "Unknown" // We don't have strings in core:data yet, but we can fix this later
},
- headerTowards = getString(Res.string.traceroute_route_towards_dest),
- headerBack = getString(Res.string.traceroute_route_back_to_us),
+ headerTowards = "Route towards destination:",
+ headerBack = "Route back to us:",
) ?: return
val requestId = packet.decoded?.request_id ?: 0
@@ -87,7 +86,7 @@ constructor(
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Traceroute $requestId complete in $seconds s" }
- val durationText = getString(Res.string.traceroute_duration, "%.1f".format(Locale.US, seconds))
+ val durationText = "Duration: %.1f s".format(Locale.US, seconds)
"$full\n\n$durationText"
} else {
full
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
similarity index 97%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
index d189f19f7..d4901d02b 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
@@ -29,12 +29,13 @@ import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
+import org.meshtastic.core.repository.DeviceHardwareRepository
import javax.inject.Inject
import javax.inject.Singleton
// Annotating with Singleton to ensure a single instance manages the cache
@Singleton
-class DeviceHardwareRepository
+class DeviceHardwareRepositoryImpl
@Inject
constructor(
private val remoteDataSource: DeviceHardwareRemoteDataSource,
@@ -42,7 +43,7 @@ constructor(
private val jsonDataSource: DeviceHardwareJsonDataSource,
private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource,
private val dispatchers: CoroutineDispatchers,
-) {
+) : DeviceHardwareRepository {
/**
* Retrieves device hardware information by its model ID and optional target string.
@@ -59,10 +60,10 @@ constructor(
* @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure.
*/
@Suppress("LongMethod", "detekt:CyclomaticComplexMethod")
- suspend fun getDeviceHardwareByModel(
+ override suspend fun getDeviceHardwareByModel(
hwModel: Int,
- target: String? = null,
- forceRefresh: Boolean = false,
+ target: String?,
+ forceRefresh: Boolean,
): Result = withContext(dispatchers.io) {
Logger.d {
"DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel," +
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
similarity index 67%
rename from core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
rename to core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
index 53729ce48..a6af8c51e 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
@@ -40,13 +40,16 @@ import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
-import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.util.onlineTimeThreshold
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
@@ -56,7 +59,7 @@ import javax.inject.Singleton
/** Repository for managing node-related data, including hardware info, node database, and identity. */
@Singleton
@Suppress("TooManyFunctions")
-open class NodeRepository
+class NodeRepositoryImpl
@Inject
constructor(
@ProcessLifecycle private val processLifecycle: Lifecycle,
@@ -64,28 +67,29 @@ constructor(
private val nodeInfoWriteDataSource: NodeInfoWriteDataSource,
private val dispatchers: CoroutineDispatchers,
private val localStatsDataSource: LocalStatsDataSource,
-) {
+) : NodeRepository {
/** Hardware info about our local device (can be null if not connected). */
- open val myNodeInfo: StateFlow =
+ override val myNodeInfo: StateFlow =
nodeInfoReadDataSource
.myNodeInfoFlow()
+ .map { it?.toMyNodeInfo() }
.flowOn(dispatchers.io)
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
private val _ourNodeInfo = MutableStateFlow(null)
/** Information about the locally connected node, as seen from the mesh. */
- open val ourNodeInfo: StateFlow
+ override val ourNodeInfo: StateFlow
get() = _ourNodeInfo
private val _myId = MutableStateFlow(null)
/** The unique userId (hex string) of our local node. */
- val myId: StateFlow
+ override val myId: StateFlow
get() = _myId
/** The latest local stats telemetry received from the locally connected node. */
- val localStats: StateFlow =
+ override val localStats: StateFlow =
localStatsDataSource.localStatsFlow.stateIn(
processLifecycle.coroutineScope,
SharingStarted.Eagerly,
@@ -93,12 +97,12 @@ constructor(
)
/** Update the cached local stats telemetry. */
- fun updateLocalStats(stats: LocalStats) {
+ override fun updateLocalStats(stats: LocalStats) {
processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) }
}
/** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */
- val nodeDBbyNum: StateFlow> =
+ override val nodeDBbyNum: StateFlow> =
nodeInfoReadDataSource
.nodeDBbyNumFlow()
.mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } }
@@ -115,7 +119,7 @@ constructor(
}
// Keep ourNodeInfo and myId correctly updated based on current connection and node DB
- combine(nodeDBbyNum, myNodeInfo) { db, info -> info?.myNodeNum?.let { db[it] } }
+ combine(nodeDBbyNum, nodeInfoReadDataSource.myNodeInfoFlow()) { db, info -> info?.myNodeNum?.let { db[it] } }
.onEach { node ->
_ourNodeInfo.value = node
_myId.value = node?.user?.id
@@ -127,7 +131,8 @@ constructor(
* Returns the node number used for log queries. Maps [nodeNum] to [MeshLog.NODE_NUM_LOCAL] (0) if it is the locally
* connected node.
*/
- fun effectiveLogNodeId(nodeNum: Int): Flow = myNodeInfo
+ override fun effectiveLogNodeId(nodeNum: Int): Flow = nodeInfoReadDataSource
+ .myNodeInfoFlow()
.map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum }
.distinctUntilChanged()
@@ -135,14 +140,14 @@ constructor(
nodeInfoReadDataSource.nodeDBbyNumFlow().map { map -> map.mapValues { (_, it) -> it.toEntity() } }
/** Returns the [Node] associated with a given [userId]. Falls back to a generic node if not found. */
- fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
+ override fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId))
/** Returns the [User] info for a given [nodeNum]. */
- fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
+ override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
/** Returns the [User] info for a given [userId]. Falls back to a generic user if not found. */
- fun getUser(userId: String): User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
+ override fun getUser(userId: String): User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
?: User(
id = userId,
long_name =
@@ -161,13 +166,13 @@ constructor(
)
/** Returns a flow of nodes filtered and sorted according to the parameters. */
- fun getNodes(
- sort: NodeSortOption = NodeSortOption.LAST_HEARD,
- filter: String = "",
- includeUnknown: Boolean = true,
- onlyOnline: Boolean = false,
- onlyDirect: Boolean = false,
- ) = nodeInfoReadDataSource
+ override fun getNodes(
+ sort: NodeSortOption,
+ filter: String,
+ includeUnknown: Boolean,
+ onlyOnline: Boolean,
+ onlyDirect: Boolean,
+ ): Flow> = nodeInfoReadDataSource
.getNodesFlow(
sort = sort.sqlValue,
filter = filter,
@@ -179,44 +184,46 @@ constructor(
.flowOn(dispatchers.io)
.conflate()
- /** Upserts a [NodeEntity] to the database. */
- suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node) }
+ /** Upserts a [Node] to the database. */
+ override suspend fun upsert(node: Node) =
+ withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node.toEntity()) }
/** Installs initial configuration data (local info and remote nodes) into the database. */
- suspend fun installConfig(mi: MyNodeEntity, nodes: List) =
- withContext(dispatchers.io) { nodeInfoWriteDataSource.installConfig(mi, nodes) }
+ override suspend fun installConfig(mi: MyNodeInfo, nodes: List