From e6cf616bdb2b14165b98e1e3f8c6fe6d2efd49f1 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:56:05 +0000 Subject: [PATCH] refactor: keep one copy of shared code across platforms (#7432) --- .skills/compose-ui/strings-index.txt | 7 +- .../org/meshtastic/app/map/MapViewModel.kt | 15 -- .../org/meshtastic/app/map/MapViewModel.kt | 45 ++-- .../offline/pmtiles/WebMercatorTileMath.kt | 39 +--- .../offline/terrain/TerrainDownloadPlanner.kt | 28 +-- .../map/MapViewModelSitePlannerRequestTest.kt | 204 ------------------ .../core/common/util/DurationFormatter.kt | 64 ++++++ .../core/common/util/LocationUtils.kt | 3 +- .../core/common/util/DurationFormatterTest.kt | 59 +++++ .../core/common/util/LocationUtilsTest.kt | 7 + .../BootloaderOtaQuirksRepositoryImplTest.kt | 70 ++---- .../DeviceHardwareRepositoryImplTest.kt | 97 +++------ .../DeviceLinkRepositoryImplTest.kt | 115 ++++------ .../EventFirmwareRepositoryImplTest.kt | 102 +++------ .../core/data/repository/FakeApiService.kt | 110 ++++++++++ .../data/repository/FakeBundledAssetReader.kt | 48 +++++ .../FirmwareReleaseRepositoryImplTest.kt | 126 ++++------- .../MaintenanceUf2RepositoryImplTest.kt | 70 ++---- .../core/model/util/AirQualityIndex.kt | 5 +- .../core/model/util/DateTimeUtils.kt | 21 -- .../core/model/util/GeoConstants.kt | 3 - .../core/model/util/TimeConstants.kt | 2 + core/resources/build.gradle.kts | 2 + .../composeResources/values/strings.xml | 7 +- .../meshtastic/core/resources/DurationText.kt | 49 +++++ .../core/service/EncryptedPreferences.kt | 46 ++++ .../service/LockdownPassphraseStoreImpl.kt | 30 +-- .../service/MeshNotificationManagerImpl.kt | 45 ++-- .../service/SecurityKeyBackupStoreImpl.kt | 27 +-- .../StartMeshServiceOnAddressChange.kt | 28 +++ .../service/di/CoreServiceAndroidModule.kt | 78 +------ .../core/service/DeviceAddressChangeHook.kt | 13 +- .../core/service/RadioControllerImpl.kt | 19 +- .../core/service/RadioControllerImplTest.kt | 5 +- .../service/RadioControllerRestoreTest.kt | 4 +- .../core/service/DesktopKeystoreCipher.kt | 92 ++++++++ .../meshtastic/core/service/JvmFileService.kt | 18 +- .../service/LockdownPassphraseStoreImpl.kt | 81 +------ .../service/NoopDeviceAddressChangeHook.kt | 12 +- .../service/SecurityKeyBackupStoreImpl.kt | 77 +------ .../core/service/JvmFileServiceTest.kt | 68 ++++++ .../core/service/KeystoreFixtures.kt | 34 +++ .../LockdownPassphraseStoreImplTest.kt | 30 ++- .../service/SecurityKeyBackupStoreImplTest.kt | 73 +++++++ .../core/ui/component/BuildNodeDescription.kt | 4 +- .../core/ui/component/LastHeardInfo.kt | 5 +- .../meshtastic/core/ui/util/FileExporter.kt | 61 ++++++ .../core/ui/util/ProtoExtensions.kt | 7 +- .../core/ui/util/WriteExportTest.kt | 88 ++++++++ .../desktop/di/DesktopRuntimeModule.kt | 65 ------ feature/discovery/README.md | 2 +- .../discovery/export/ExportSaver.android.kt | 65 ------ .../export/DiscoveryReportFormatter.kt | 15 +- .../discovery/export/ExportSaverLauncher.kt | 24 ++- .../ui/DiscoveryHistoryDetailScreen.kt | 4 +- .../discovery/ui/DiscoverySummaryScreen.kt | 11 +- .../ui/component/DwellProgressIndicator.kt | 6 +- .../ui/component/PresetResultCard.kt | 5 +- .../export/DiscoveryReportNodeIdTest.kt | 47 ++++ .../discovery/export/ExportSaver.jvm.kt | 53 ----- feature/firmware/build.gradle.kts | 5 +- .../firmware/AndroidFirmwareFileHandler.kt | 190 +--------------- .../feature/firmware/FirmwareFileHandler.kt | 16 +- .../feature/firmware/ota/ThroughputTracker.kt | 5 +- .../firmware/IsValidFirmwareFileTest.kt | 7 + .../firmware/BaseFirmwareFileHandler.kt | 166 ++++++++++++++ .../feature/firmware/ZipExtraction.kt | 139 +++++++++++- .../firmware/JvmFirmwareFileHandler.kt | 155 +------------ .../firmware/JvmFirmwareFileHandlerTest.kt | 85 ++++++++ .../feature/firmware/ZipExtractionTest.kt | 159 ++++++++++++++ .../map/terrain/TerrainRegionExtractor.kt | 29 +-- .../feature/map/terrain/TerrainTileCount.kt | 42 ++++ .../feature/map/terrain/TerrainTileMath.kt | 18 +- .../map/terrain/TerrainTileCountTest.kt | 45 ++++ .../map/terrain/TerrainTileMathTest.kt | 17 ++ .../feature/map/BaseMapViewModel.kt | 30 ++- .../feature}/map/SitePlannerRequestState.kt | 2 +- .../map/BaseMapViewModelSitePlannerTest.kt | 58 ++--- .../feature/node/compass/CompassUiState.kt | 2 +- .../feature/node/compass/CompassViewModel.kt | 16 +- .../feature/node/component/ChannelInfo.kt | 42 ---- .../node/component/CompassBottomSheet.kt | 8 +- .../feature/node/component/DistanceInfo.kt | 43 ---- .../feature/node/component/ElevationInfo.kt | 48 ----- .../feature/node/component/HopsInfo.kt | 39 ---- .../feature/node/component/IconInfo.kt | 61 ------ .../feature/node/component/LastHeardInfo.kt | 45 ---- .../node/component/NodeDetailsSection.kt | 4 +- .../feature/node/component/TelemetryInfo.kt | 200 ----------------- .../feature/node/metrics/DeviceMetrics.kt | 8 +- .../feature/node/metrics/HostMetricsLog.kt | 4 +- .../feature/node/metrics/PaxMetrics.kt | 22 +- .../feature/node/metrics/SignalMetrics.kt | 8 +- .../feature/settings/SettingsScreen.kt | 20 +- .../settings/component/AppInfoSection.kt | 58 ----- .../feature/settings/debugging/LogExporter.kt | 54 ----- .../feature/settings/tak/PrefExporter.kt | 51 ----- .../feature/settings/AdministrationScreen.kt | 19 +- .../settings/DeviceConfigurationScreen.kt | 19 +- .../settings/ModuleConfigurationScreen.kt | 19 +- .../settings/component/AppVersionButton.kt | 82 +++++++ .../settings/component/RadioAdminAppBar.kt | 60 ++++++ .../settings/debugging/DebugViewModel.kt | 12 +- .../settings/debugging/LogExportLauncher.kt | 59 +++++ .../feature/settings/debugging/LogExporter.kt | 5 - .../settings/lockdown/LockdownDialog.kt | 117 ++-------- .../settings/lockdown/LockdownFields.kt | 164 ++++++++++++++ .../settings/lockdown/LockdownModeSetting.kt | 118 ++-------- .../lockdown/LockdownSessionStatus.kt | 4 +- .../radio/component/SecurityConfigScreen.kt | 5 +- .../feature/settings/tak/PrefExporter.kt | 9 +- .../LockdownPassphraseValidationTest.kt | 38 ++++ .../feature/settings/debugging/NoopStubs.kt | 4 - .../feature/settings/DesktopSettingsScreen.kt | 78 +------ .../feature/settings/debugging/LogExporter.kt | 51 ----- .../feature/settings/tak/PrefExporter.kt | 54 ----- .../feature/widget/LocalStatsWidget.kt | 4 +- 117 files changed, 2508 insertions(+), 2854 deletions(-) delete mode 100644 androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/DurationFormatter.kt create mode 100644 core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/DurationFormatterTest.kt create mode 100644 core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FakeApiService.kt create mode 100644 core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FakeBundledAssetReader.kt create mode 100644 core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/DurationText.kt create mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/EncryptedPreferences.kt create mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/StartMeshServiceOnAddressChange.kt rename feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt => core/service/src/commonMain/kotlin/org/meshtastic/core/service/DeviceAddressChangeHook.kt (72%) create mode 100644 core/service/src/jvmMain/kotlin/org/meshtastic/core/service/DesktopKeystoreCipher.kt rename feature/discovery/src/iosMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.ios.kt => core/service/src/jvmMain/kotlin/org/meshtastic/core/service/NoopDeviceAddressChangeHook.kt (69%) create mode 100644 core/service/src/jvmTest/kotlin/org/meshtastic/core/service/JvmFileServiceTest.kt create mode 100644 core/service/src/jvmTest/kotlin/org/meshtastic/core/service/KeystoreFixtures.kt create mode 100644 core/service/src/jvmTest/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImplTest.kt create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/FileExporter.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/WriteExportTest.kt delete mode 100644 feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.android.kt create mode 100644 feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportNodeIdTest.kt delete mode 100644 feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.jvm.kt create mode 100644 feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/BaseFirmwareFileHandler.kt create mode 100644 feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandlerTest.kt create mode 100644 feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCount.kt create mode 100644 feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCountTest.kt rename {androidApp/src/main/kotlin/org/meshtastic/app => feature/map/src/commonMain/kotlin/org/meshtastic/feature}/map/SitePlannerRequestState.kt (98%) rename androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt => feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelSitePlannerTest.kt (71%) delete mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ChannelInfo.kt delete mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt delete mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt delete mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt delete mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/IconInfo.kt delete mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt delete mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt delete mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/AppVersionButton.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/RadioAdminAppBar.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExportLauncher.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownFields.kt create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/lockdown/LockdownPassphraseValidationTest.kt delete mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 7f4849a3ac..bd7925e71d 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -173,7 +173,6 @@ channel_7 channel_8 channel_features channel_invalid -channel_label channel_name channel_url channel_utilization @@ -521,6 +520,12 @@ downlink_feature_description download download_this_area duplicated_public_key_title +### DURATION ### +duration_ago +duration_days_short +duration_hours_short +duration_minutes_short +duration_seconds_short dynamic easily_set_up_private_mesh_networks edit diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt index dde5346001..23d4f57bf6 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.LocaleUnitsProvider -import org.meshtastic.core.model.Node import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.repository.MapCameraPosition import org.meshtastic.core.repository.MapPrefs @@ -33,7 +32,6 @@ import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController -import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.feature.map.layers.MapLayerItem import org.meshtastic.feature.map.layers.MapLayersManager @@ -111,19 +109,6 @@ class MapViewModel( fun refreshAllVisibleNetworkLayers() = mapLayersManager.refreshAllVisibleNetworkLayers() suspend fun readLayerBytes(layerItem: MapLayerItem): ByteArray? = mapLayersManager.readLayerBytes(layerItem) - - // Injected by the map provider because this SavedStateHandle is not the Navigation 3 entry's route state. - private val sitePlannerRequestState = SitePlannerRequestState(nodeRepository.nodeDBbyNum) - val sitePlannerRequest: StateFlow = - sitePlannerRequestState.request.stateInWhileSubscribed(initialValue = null) - - fun setSitePlannerNodeNum(nodeNum: Int?) { - sitePlannerRequestState.setNodeNum(nodeNum) - } - - fun consumeSitePlannerRequest(nodeNum: Int) { - sitePlannerRequestState.consume(nodeNum) - } } internal sealed interface InitialCameraState { diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index 0281835ec9..ba29679e89 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -61,7 +61,6 @@ import org.meshtastic.app.map.tiles.RasterTileProvider import org.meshtastic.app.map.tiles.toRasterBasemap import org.meshtastic.core.common.util.LocaleUnitsProvider import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.repository.MapPrefs @@ -142,19 +141,6 @@ class MapViewModel( private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) val selectedWaypointId: StateFlow = _selectedWaypointId.asStateFlow() - // Injected by the map provider because this SavedStateHandle is not the Navigation 3 entry's route state. - private val sitePlannerRequestState = SitePlannerRequestState(nodeRepository.nodeDBbyNum) - val sitePlannerRequest: StateFlow = - sitePlannerRequestState.request.stateInWhileSubscribed(initialValue = null) - - fun setSitePlannerNodeNum(nodeNum: Int?) { - sitePlannerRequestState.setNodeNum(nodeNum) - } - - fun consumeSitePlannerRequest(nodeNum: Int) { - sitePlannerRequestState.consume(nodeNum) - } - fun setWaypointId(id: Int?) { if (_selectedWaypointId.value != id) { _selectedWaypointId.value = id @@ -630,23 +616,22 @@ class MapViewModel( _terrainDownloadRegionId.value = regionId _terrainDownloadState.value = null - terrainDownloadJob = - viewModelScope.launch { - val store = terrainStoreForRegion(regionId) - val bounds = - GeoBounds( - south = region.southLat, - west = region.westLon, - north = region.northLat, - east = region.eastLon, - ) - val maxZoom = TerrainDownloadPlanner.maxZoomFitting(bounds, TerrainRegionExtractor.MAX_TILES) - // flowOn: the extractor does blocking per-tile HTTP on its collector's dispatcher. - TerrainRegionExtractor(store).download(bounds, maxZoom).flowOn(dispatchers.io).collect { state -> - _terrainDownloadState.value = state - if (state is TerrainDownloadState.Complete) attachTerrain(region, state, store) - } + terrainDownloadJob = viewModelScope.launch { + val store = terrainStoreForRegion(regionId) + val bounds = + GeoBounds( + south = region.southLat, + west = region.westLon, + north = region.northLat, + east = region.eastLon, + ) + val maxZoom = TerrainDownloadPlanner.maxZoomFitting(bounds, TerrainRegionExtractor.MAX_TILES) + // flowOn: the extractor does blocking per-tile HTTP on its collector's dispatcher. + TerrainRegionExtractor(store).download(bounds, maxZoom).flowOn(dispatchers.io).collect { state -> + _terrainDownloadState.value = state + if (state is TerrainDownloadState.Complete) attachTerrain(region, state, store) } + } } private suspend fun attachTerrain( diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/pmtiles/WebMercatorTileMath.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/pmtiles/WebMercatorTileMath.kt index c9dbb6234a..64ea5394ea 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/pmtiles/WebMercatorTileMath.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/pmtiles/WebMercatorTileMath.kt @@ -17,49 +17,26 @@ package org.meshtastic.app.map.offline.pmtiles import com.google.android.gms.maps.model.LatLng -import kotlin.math.PI -import kotlin.math.asinh -import kotlin.math.atan -import kotlin.math.pow -import kotlin.math.sinh -import kotlin.math.tan +import org.meshtastic.feature.map.terrain.TerrainTileMath -/** Standard XYZ/slippy-map Web Mercator conversions, shared by tile enumeration and MVT-local-coordinate placement. */ +/** [TerrainTileMath] in Google Maps [LatLng] terms, for tile enumeration and MVT-local-coordinate placement. */ internal object WebMercatorTileMath { /** The tile (at [zoom]) containing [latLng] — the same indexing GoogleMap, MapLibre and PMTiles all share. */ - fun tileAt(zoom: Int, latLng: LatLng): TileIndex { - val n = 2.0.pow(zoom) - val latRad = Math.toRadians(latLng.latitude.coerceIn(-MAX_LATITUDE, MAX_LATITUDE)) - val x = ((latLng.longitude + LON_RANGE_DEG) / FULL_LON_RANGE_DEG * n).toInt().coerceIn(0, (n - 1).toInt()) - val y = ((1.0 - asinh(tan(latRad)) / PI) / 2.0 * n).toInt().coerceIn(0, (n - 1).toInt()) - return TileIndex(zoom, x, y) - } + fun tileAt(zoom: Int, latLng: LatLng): TileIndex = TerrainTileMath.tileAt(zoom, latLng.latitude, latLng.longitude) /** Places a feature's tile-local point (`0 until extent` on each axis) at its real-world [LatLng]. */ fun tileLocalToLatLng(tile: TileIndex, extent: Int, local: TileCoord): LatLng = tileFractionalToLatLng(tile.zoom, tile.x, tile.y, local.x.toDouble() / extent, local.y.toDouble() / extent) /** - * Places a fractional `[0,1]×[0,1]` tile-local point — [org.meshtastic.feature.map.terrain.ContourPoint]'s own - * convention — at its real-world [LatLng]. A standalone zoom/x/y overload rather than one keyed by this file's own - * [TileIndex], so callers outside this package (the offline terrain contour renderer, which has its own same-shaped - * but distinct `TileIndex`) can reuse this math without a type-conversion shim. + * Places a fractional `[0,1]×[0,1]` tile-local point, [org.meshtastic.feature.map.terrain.ContourPoint]'s own + * convention, at its real-world [LatLng]. */ fun tileFractionalToLatLng(zoom: Int, tileX: Int, tileY: Int, fracX: Double, fracY: Double): LatLng { - val n = 2.0.pow(zoom) - val fx = tileX + fracX - val fy = tileY + fracY - val lon = fx / n * FULL_LON_RANGE_DEG - LON_RANGE_DEG - val latRad = atan(sinh(PI * (1 - 2 * fy / n))) - return LatLng(Math.toDegrees(latRad), lon) + val point = TerrainTileMath.lonLatAt(zoom, tileX + fracX, tileY + fracY) + return LatLng(point.latitude, point.longitude) } - - private const val LON_RANGE_DEG = 180.0 - private const val FULL_LON_RANGE_DEG = 360.0 - - /** Web Mercator's own latitude ceiling (~85.0511°), where the projection would otherwise reach infinity. */ - private const val MAX_LATITUDE = 85.05112878 } -internal data class TileIndex(val zoom: Int, val x: Int, val y: Int) +internal typealias TileIndex = org.meshtastic.feature.map.terrain.TileIndex diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/terrain/TerrainDownloadPlanner.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/terrain/TerrainDownloadPlanner.kt index bc04dd1f0a..662983e3f6 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/terrain/TerrainDownloadPlanner.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/terrain/TerrainDownloadPlanner.kt @@ -18,7 +18,7 @@ package org.meshtastic.app.map.offline.terrain import org.meshtastic.feature.map.terrain.GeoBounds import org.meshtastic.feature.map.terrain.MapterhornEndpoints -import org.meshtastic.feature.map.terrain.TerrainTileMath +import org.meshtastic.feature.map.terrain.terrainTileCount /** * Picks how deep a terrain download should go before starting one. @@ -42,32 +42,8 @@ internal object TerrainDownloadPlanner { */ fun maxZoomFitting(bounds: GeoBounds, maxTiles: Int): Int { for (zoom in MapterhornEndpoints.REGIONAL_MAX_ZOOM downTo MapterhornEndpoints.GLOBAL_MAX_ZOOM) { - if (tileCount(bounds, zoom) <= maxTiles) return zoom + if (terrainTileCount(bounds, zoom) <= maxTiles) return zoom } return MapterhornEndpoints.GLOBAL_MAX_ZOOM } - - /** - * Mirrors [org.meshtastic.feature.map.terrain.TerrainRegionExtractor.download]'s own tile enumeration, but via - * [TerrainTileMath.tileCountAt] rather than materializing each zoom level's tile list — this function walks zoom 18 - * down to 12 on every call, and materializing to count at the deep end would be the same - * count-before-you-can-afford-to-materialize problem the extractor itself was fixed for. - */ - private fun tileCount(bounds: GeoBounds, maxZoom: Int): Long { - val globalZoomRange = 0..minOf(maxZoom, MapterhornEndpoints.GLOBAL_MAX_ZOOM) - val globalCount = globalZoomRange.sumOf { zoom -> TerrainTileMath.tileCountAt(zoom, bounds) } - - val regionalUrl = - if (maxZoom > MapterhornEndpoints.GLOBAL_MAX_ZOOM) MapterhornEndpoints.regionalUrlFor(bounds) else null - val regionalCount = - if (regionalUrl == null) { - 0L - } else { - val regionalZoomRange = - MapterhornEndpoints.REGIONAL_MIN_ZOOM..minOf(maxZoom, MapterhornEndpoints.REGIONAL_MAX_ZOOM) - regionalZoomRange.sumOf { zoom -> TerrainTileMath.tileCountAt(zoom, bounds) } - } - - return globalCount + regionalCount - } } diff --git a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt b/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt deleted file mode 100644 index d998cf8a7a..0000000000 --- a/androidApp/src/testGoogle/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt +++ /dev/null @@ -1,204 +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 org.meshtastic.app.map - -import android.app.Application -import androidx.lifecycle.SavedStateHandle -import androidx.test.core.app.ApplicationProvider -import app.cash.turbine.test -import com.google.maps.android.compose.MapType -import dev.mokkery.MockMode -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.everySuspend -import dev.mokkery.mock -import io.ktor.client.HttpClient -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.runCurrent -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import okio.Path.Companion.toOkioPath -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.app.map.prefs.map.GoogleCameraPosition -import org.meshtastic.app.map.prefs.map.GoogleMapSelectionPrefs -import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs -import org.meshtastic.app.map.tiles.MapTileHttpClient -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.Node -import org.meshtastic.core.network.repository.NetworkRepository -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.testing.FakeBuildConfigProvider -import org.meshtastic.core.testing.FakeLocaleUnitsProvider -import org.meshtastic.core.testing.FakeMapPrefs -import org.meshtastic.core.testing.FakeMapTileProviderPrefs -import org.meshtastic.core.testing.FakeNodeRepository -import org.meshtastic.core.testing.FakeNotificationPrefs -import org.meshtastic.core.testing.FakeRadioConfigRepository -import org.meshtastic.core.testing.FakeRadioController -import org.meshtastic.core.testing.FakeUiPrefs -import org.meshtastic.feature.map.layers.LayerOpacityStore -import org.meshtastic.feature.map.layers.MapLayersManager -import org.meshtastic.feature.map.tiles.CustomTileProviderConfig -import org.meshtastic.feature.map.tiles.CustomTileProviderLoadResult -import org.meshtastic.feature.map.tiles.CustomTileProviderRepository -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.io.path.createTempDirectory -import kotlin.test.assertEquals -import kotlin.test.assertNull -import java.nio.file.Path as NioPath - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class) -class MapViewModelSitePlannerRequestTest { - - private val testDispatcher = StandardTestDispatcher() - private val nodeRepository = FakeNodeRepository() - private val packetRepository = mock(MockMode.autofill) - private val mapPrefs = FakeMapPrefs() - private val googleMapsPrefs = mock(MockMode.autofill) - private val customTileProviderRepository = mock(MockMode.autofill) - private val networkRepository = mock(MockMode.autofill) - private val firstNode = Node(num = 11) - private val secondNode = Node(num = 22) - private lateinit var httpClient: HttpClient - private lateinit var layersDir: NioPath - private lateinit var mapLayersManager: MapLayersManager - private lateinit var viewModel: MapViewModel - - @Before - fun setUp() { - Dispatchers.setMain(testDispatcher) - every { packetRepository.getWaypoints() } returns flowOf(emptyList()) - every { networkRepository.networkAvailable } returns flowOf(true) - httpClient = HttpClient() - layersDir = createTempDirectory("map-layers") - mapLayersManager = - MapLayersManager( - dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher), - httpClient = httpClient, - mapPrefs = mapPrefs, - // The real location reads a global application context this test never installs. - layersDir = layersDir.toOkioPath(), - ) - every { googleMapsPrefs.cameraPosition } returns flowOf(null) - every { googleMapsPrefs.selectedCustomTileUrl } returns MutableStateFlow(null) - every { googleMapsPrefs.selectedGoogleMapType } returns MutableStateFlow(null) - everySuspend { googleMapsPrefs.awaitMapSelection() } returns - GoogleMapSelectionPrefs(mapType = MapType.NORMAL.name, customTileUrl = null) - every { customTileProviderRepository.getCustomTileProviders() } returns - flowOf>(emptyList()) - // autofill cannot synthesize the load result, and the ViewModel init dereferences it. - everySuspend { customTileProviderRepository.awaitCustomTileProviders() } returns - CustomTileProviderLoadResult(providers = emptyList(), isSuccessful = true) - - nodeRepository.setNodes(listOf(firstNode, secondNode)) - viewModel = - MapViewModel( - application = ApplicationProvider.getApplicationContext(), - dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher), - mapLayersManager = mapLayersManager, - layerOpacityStore = - LayerOpacityStore(mapPrefs, CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher)), - mapPrefs = mapPrefs, - googleMapsPrefs = googleMapsPrefs, - nodeRepository = nodeRepository, - packetRepository = packetRepository, - radioConfigRepository = FakeRadioConfigRepository(), - radioController = FakeRadioController(), - customTileProviderRepository = customTileProviderRepository, - mapTileProviderPrefs = FakeMapTileProviderPrefs(), - mapTileHttpClient = - MapTileHttpClient(ApplicationProvider.getApplicationContext(), FakeBuildConfigProvider()), - uiPrefs = FakeUiPrefs(), - notificationPrefs = FakeNotificationPrefs(), - savedStateHandle = SavedStateHandle(), - localeUnitsProvider = FakeLocaleUnitsProvider(), - networkRepository = networkRepository, - ) - } - - @After - fun tearDown() { - httpClient.close() - layersDir.toFile().deleteRecursively() - Dispatchers.resetMain() - } - - @Test - fun `consumed site planner request is not rearmed by entry recomposition`() = runTest(testDispatcher) { - viewModel.sitePlannerRequest.test { - assertNull(awaitItem()) - - viewModel.setSitePlannerNodeNum(firstNode.num) - assertEquals(firstNode, awaitItem()) - - viewModel.consumeSitePlannerRequest(firstNode.num) - assertNull(awaitItem()) - - viewModel.setSitePlannerNodeNum(firstNode.num) - runCurrent() - expectNoEvents() - assertNull(viewModel.sitePlannerRequest.value) - } - } - - @Test - fun `new route request replaces pending request and stale consumption cannot clear it`() = runTest(testDispatcher) { - viewModel.sitePlannerRequest.test { - assertNull(awaitItem()) - - viewModel.setSitePlannerNodeNum(firstNode.num) - assertEquals(firstNode, awaitItem()) - viewModel.setSitePlannerNodeNum(secondNode.num) - assertEquals(secondNode, awaitItem()) - - viewModel.consumeSitePlannerRequest(firstNode.num) - expectNoEvents() - assertEquals(secondNode, viewModel.sitePlannerRequest.value) - } - } - - @Test - fun `pending request follows the live node and stops after consumption`() = runTest(testDispatcher) { - viewModel.sitePlannerRequest.test { - assertNull(awaitItem()) - - viewModel.setSitePlannerNodeNum(firstNode.num) - assertEquals(firstNode, awaitItem()) - - val updatedNode = firstNode.copy(notes = "updated while pending") - nodeRepository.setNodes(listOf(updatedNode, secondNode)) - assertEquals(updatedNode, awaitItem()) - - viewModel.consumeSitePlannerRequest(updatedNode.num) - assertNull(awaitItem()) - - nodeRepository.setNodes(listOf(firstNode, secondNode)) - expectNoEvents() - } - } -} diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/DurationFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/DurationFormatter.kt new file mode 100644 index 0000000000..242f1a8905 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/DurationFormatter.kt @@ -0,0 +1,64 @@ +/* + * 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.common.util + +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +/** + * Formats [totalSeconds] as its non-zero day, hour, minute and second parts, largest first: `1d 2h 3m 4s`. Parts finer + * than [smallest] are dropped, and a duration with no part left prints zero of [smallest]. Negative input counts as + * zero. + */ +fun formatDuration( + totalSeconds: Long, + labels: DurationUnitLabels, + smallest: DurationUnit = DurationUnit.SECONDS, +): String { + val parts = + totalSeconds + .coerceAtLeast(0) + .seconds + .toComponents { days, hours, minutes, seconds, _ -> + listOf( + DurationUnit.DAYS to days, + DurationUnit.HOURS to hours.toLong(), + DurationUnit.MINUTES to minutes.toLong(), + DurationUnit.SECONDS to seconds.toLong(), + ) + } + .filter { (unit, _) -> unit >= smallest } + val shown = parts.filter { (_, count) -> count > 0 }.ifEmpty { listOf(parts.last().first to 0L) } + return shown.joinToString(" ") { (unit, count) -> formatString(labels.template(unit), count) } +} + +/** + * The per-unit templates [formatDuration] fills with a count as `%1$d`. Screens use the translated set from + * `core:resources`; [English] is for text that is never translated, such as an exported report. + */ +data class DurationUnitLabels(val days: String, val hours: String, val minutes: String, val seconds: String) { + companion object { + val English = DurationUnitLabels(days = "%1\$dd", hours = "%1\$dh", minutes = "%1\$dm", seconds = "%1\$ds") + } +} + +private fun DurationUnitLabels.template(unit: DurationUnit): String = when (unit) { + DurationUnit.DAYS -> days + DurationUnit.HOURS -> hours + DurationUnit.MINUTES -> minutes + else -> seconds +} diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt index bdb13eac8a..82535d5cde 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt @@ -39,7 +39,8 @@ object GPSFormat { } } -private const val EARTH_RADIUS_METERS = 6371e3 +/** Mean radius of the Earth in meters, the sphere every distance here is measured on. */ +const val EARTH_RADIUS_METERS = 6_371_000.0 @Suppress("MagicNumber") private fun Double.toRadians(): Double = this * PI / 180.0 diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/DurationFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/DurationFormatterTest.kt new file mode 100644 index 0000000000..c1fa4c22aa --- /dev/null +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/DurationFormatterTest.kt @@ -0,0 +1,59 @@ +/* + * 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.common.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.DurationUnit + +class DurationFormatterTest { + private val english = DurationUnitLabels.English + + @Test + fun `every non-zero part is shown largest first`() { + assertEquals("1d 2h 3m 4s", formatDuration(93_784, english)) + } + + @Test + fun `zero parts in the middle are skipped`() { + assertEquals("1h 5s", formatDuration(3_605, english)) + } + + @Test + fun `zero prints zero of the smallest unit`() { + assertEquals("0s", formatDuration(0, english)) + assertEquals("0m", formatDuration(59, english, smallest = DurationUnit.MINUTES)) + } + + @Test + fun `parts finer than the smallest unit are dropped`() { + assertEquals("1d 1h", formatDuration(90_059, english, smallest = DurationUnit.MINUTES)) + assertEquals("25m", formatDuration(1_530, english, smallest = DurationUnit.MINUTES)) + } + + @Test + fun `negative input counts as zero`() { + assertEquals("0s", formatDuration(-5, english)) + } + + @Test + fun `labels are filled per unit`() { + val labels = DurationUnitLabels(days = "%1\$d j", hours = "%1\$d h", minutes = "%1\$d min", seconds = "%1\$d s") + + assertEquals("2 j 3 min", formatDuration(2 * 86_400L + 180, labels)) + } +} diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt index db59a52d40..6c4bc47ad3 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt @@ -39,6 +39,13 @@ class LocationUtilsTest { assertTrue(distance2 > 78000 && distance2 < 79000, "Distance was $distance2") } + @Test + fun `distance uses the mean earth radius`() { + // One degree of arc on a 6,371 km sphere, and London to Paris on the same sphere. + assertEquals(111_194.93, latLongToMeter(0.0, 0.0, 0.0, 1.0), absoluteTolerance = 0.01) + assertEquals(343_556.06, latLongToMeter(51.5074, -0.1278, 48.8566, 2.3522), absoluteTolerance = 0.01) + } + @Test fun testBearing() { // North diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/BootloaderOtaQuirksRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/BootloaderOtaQuirksRepositoryImplTest.kt index 8579963011..d6a4704cba 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/BootloaderOtaQuirksRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/BootloaderOtaQuirksRepositoryImplTest.kt @@ -19,62 +19,22 @@ package org.meshtastic.core.data.repository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json -import okio.Buffer -import okio.Source import org.meshtastic.core.data.datasource.BootloaderOtaQuirksLocalDataSource -import org.meshtastic.core.data.datasource.BundledAssetReader import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.BootloaderOtaQuirk import org.meshtastic.core.model.BootloaderOtaQuirksResponse -import org.meshtastic.core.model.EventFirmwareResponse -import org.meshtastic.core.model.FirmwareReleaseManifest -import org.meshtastic.core.model.MaintenanceUf2Manifest -import org.meshtastic.core.model.NetworkDeviceHardware -import org.meshtastic.core.model.NetworkDeviceLinksResponse -import org.meshtastic.core.model.NetworkFirmwareNightly -import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.model.SoftDeviceVariantEntry import org.meshtastic.core.network.BootloaderOtaQuirksRemoteDataSource -import org.meshtastic.core.network.service.ApiService import org.meshtastic.core.testing.FakeDatabaseProvider import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +private const val QUIRKS_ASSET = "device_bootloader_ota_quirks.json" + class BootloaderOtaQuirksRepositoryImplTest { - /** Only [getBootloaderOtaQuirks] is exercised; the other endpoints are never called by this repository. */ - private class FakeApiService(var response: BootloaderOtaQuirksResponse) : ApiService { - override suspend fun getDeviceHardware(): List = error("unused") - - override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused") - - override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused") - - override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = error("unused") - - override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused") - - override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused") - - override suspend fun getBootloaderOtaQuirks(): BootloaderOtaQuirksResponse = response - - override suspend fun getMaintenanceUf2Manifest(): MaintenanceUf2Manifest = error("unused") - } - - /** - * Serves only `device_bootloader_ota_quirks.json`, or nothing when [seed] is null (models the asset being absent). - */ - private class FakeBundledAssetReader(var seed: BootloaderOtaQuirksResponse?, private val json: Json) : - BundledAssetReader { - override fun open(name: String): Source? { - if (name != "device_bootloader_ota_quirks.json") return null - val current = seed ?: return null - return Buffer().write(json.encodeToString(current).encodeToByteArray()) - } - } - private val json = Json { ignoreUnknownKeys = true } // Real dispatchers + runBlocking, not runTest — reconcile() has no virtual-time interaction to worry about, but @@ -98,8 +58,8 @@ class BootloaderOtaQuirksRepositoryImplTest { fun setup() { dbProvider = FakeDatabaseProvider() local = BootloaderOtaQuirksLocalDataSource(dbProvider, dispatchers) - api = FakeApiService(BootloaderOtaQuirksResponse()) - seed = FakeBundledAssetReader(null, json) + api = FakeApiService(bootloaderOtaQuirks = { BootloaderOtaQuirksResponse() }) + seed = FakeBundledAssetReader() repository = BootloaderOtaQuirksRepositoryImpl( remoteDataSource = BootloaderOtaQuirksRemoteDataSource(api, dispatchers), @@ -114,11 +74,14 @@ class BootloaderOtaQuirksRepositoryImplTest { @Test fun getSnapshotSeedsFromBundledJsonWhenCacheIsEmpty() = runBlocking { - seed.seed = + seed.put( + QUIRKS_ASSET, BootloaderOtaQuirksResponse( devices = listOf(quirk(hwModel = 9)), softDeviceVariants = listOf(variant(hwModel = 9, target = "rak4631", softDevice = "6.1.1")), - ) + ), + json, + ) val snapshot = repository.getSnapshot() @@ -128,12 +91,16 @@ class BootloaderOtaQuirksRepositoryImplTest { @Test fun getSnapshotSeedsOnlyWhenCacheIsEmpty() = runBlocking { - seed.seed = BootloaderOtaQuirksResponse(devices = listOf(quirk(hwModel = 9))) + seed.put(QUIRKS_ASSET, BootloaderOtaQuirksResponse(devices = listOf(quirk(hwModel = 9))), json) repository.getSnapshot() assertEquals(1, local.count()) // A changed bundled asset must NOT re-seed once the cache is populated. - seed.seed = BootloaderOtaQuirksResponse(devices = listOf(quirk(hwModel = 9), quirk(hwModel = 18))) + seed.put( + QUIRKS_ASSET, + BootloaderOtaQuirksResponse(devices = listOf(quirk(hwModel = 9), quirk(hwModel = 18))), + json, + ) val snapshot = repository.getSnapshot() assertEquals(1, local.count()) @@ -149,8 +116,9 @@ class BootloaderOtaQuirksRepositoryImplTest { @Test fun reconcileUpdatesCacheFromTheNetwork() = runBlocking { - api.response = + api.bootloaderOtaQuirks = { BootloaderOtaQuirksResponse(softDeviceVariants = listOf(variant(hwModel = 9, target = "rak4631", "7.3.0"))) + } repository.reconcile() val snapshot = repository.getSnapshot() @@ -160,11 +128,11 @@ class BootloaderOtaQuirksRepositoryImplTest { @Test fun emptyNetworkResponseLeavesCacheUntouched() = runBlocking { - api.response = BootloaderOtaQuirksResponse(devices = listOf(quirk(hwModel = 9))) + api.bootloaderOtaQuirks = { BootloaderOtaQuirksResponse(devices = listOf(quirk(hwModel = 9))) } repository.reconcile() assertEquals(1, local.count()) - api.response = BootloaderOtaQuirksResponse() + api.bootloaderOtaQuirks = { BootloaderOtaQuirksResponse() } repository.reconcile() assertEquals(1, local.count()) diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt index 852f80530d..fe193f3a75 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt @@ -19,14 +19,12 @@ package org.meshtastic.core.data.repository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json -import okio.Buffer -import okio.Source import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.data.datasource.BundledAssetReader import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource @@ -34,16 +32,9 @@ import org.meshtastic.core.data.datasource.decode import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.BootloaderOtaQuirksResponse import org.meshtastic.core.model.DeviceLink -import org.meshtastic.core.model.EventFirmwareResponse -import org.meshtastic.core.model.FirmwareReleaseManifest -import org.meshtastic.core.model.MaintenanceUf2Manifest import org.meshtastic.core.model.NetworkDeviceHardware -import org.meshtastic.core.model.NetworkDeviceLinksResponse -import org.meshtastic.core.model.NetworkFirmwareNightly -import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.model.SoftDeviceVariant import org.meshtastic.core.network.DeviceHardwareRemoteDataSource -import org.meshtastic.core.network.service.ApiService import org.meshtastic.core.repository.BootloaderOtaQuirksRepository import org.meshtastic.core.repository.DeviceLinkRepository import org.meshtastic.core.testing.FakeDatabaseProvider @@ -55,45 +46,9 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds class DeviceHardwareRepositoryImplTest { - private class FakeApiService(var response: List) : ApiService { - var hardwareCalls = 0 - var responseGate: CompletableDeferred? = null - - override suspend fun getDeviceHardware(): List { - hardwareCalls += 1 - responseGate?.await() - return response - } - - override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused") - - override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused") - - override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = error("unused") - - override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused") - - override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused") - - override suspend fun getBootloaderOtaQuirks(): BootloaderOtaQuirksResponse = error("unused") - - override suspend fun getMaintenanceUf2Manifest(): MaintenanceUf2Manifest = error("unused") - } - - private class FakeBundledAssetReader(var hardware: List, private val json: Json) : - BundledAssetReader { - /** Raw `device_bootloader_ota_quirks.json` body, or null to model the asset being absent entirely. */ - var quirksJson: String? = null - - override fun open(name: String): Source? = when (name) { - "device_hardware.json" -> Buffer().write(json.encodeToString(hardware).encodeToByteArray()) - "device_bootloader_ota_quirks.json" -> quirksJson?.let { Buffer().write(it.encodeToByteArray()) } - else -> null - } - } - private class FakeDeviceLinkRepository : DeviceLinkRepository { var reconcileCalls = 0 var reconcileFailure: Throwable? = null @@ -115,7 +70,7 @@ class DeviceHardwareRepositoryImplTest { * Mirrors the pre-migration `loadQuirksAsset()` behavior exactly: reads straight from the same bundled-asset fake * the hardware-catalog seed uses, fails open to an empty response on an absent or malformed asset. Real caching and * network-refresh behavior is covered separately in BootloaderOtaQuirksRepositoryImplTest; this fake exists so - * these tests can keep driving SoftDevice/quirk resolution through [FakeBundledAssetReader.quirksJson] unchanged. + * these tests can drive SoftDevice/quirk resolution through the bundled quirks asset alone. */ private class FakeBootloaderOtaQuirksRepository( private val assetReader: BundledAssetReader, @@ -163,12 +118,19 @@ class DeviceHardwareRepositoryImplTest { private lateinit var assetReader: FakeBundledAssetReader private lateinit var repository: DeviceHardwareRepositoryImpl + /** An asset reader serving [hardware] as the bundled catalog and, when given, [quirks] as the raw quirks asset. */ + private fun bundledAssets(hardware: List, quirks: String? = null) = + FakeBundledAssetReader().apply { + put("device_hardware.json", hardware, json) + quirks?.let { put("device_bootloader_ota_quirks.json", it) } + } + @BeforeTest fun setup() { databaseProvider = FakeDatabaseProvider() - api = FakeApiService(listOf(knownHardware)) + api = FakeApiService(deviceHardware = { listOf(knownHardware) }) links = FakeDeviceLinkRepository() - assetReader = FakeBundledAssetReader(listOf(knownHardware), json) + assetReader = bundledAssets(listOf(knownHardware)) repository = DeviceHardwareRepositoryImpl( remoteDataSource = DeviceHardwareRemoteDataSource(api, dispatchers), @@ -185,17 +147,24 @@ class DeviceHardwareRepositoryImplTest { @Test fun repeatedMissingModelUsesOneSuccessfulCatalogRefresh() = runBlocking { - // Sequential lookups can hit cache after the first fetch and never share a flight; prove single-flight - // sharing by suspending the fake API response and overlapping the two lookups. - api.responseGate = CompletableDeferred() + // The second lookup runs to completion while the first fetch is held open, so the success TTL cannot be + // what stops it from fetching again. + val fetchStarted = CompletableDeferred() + val gate = CompletableDeferred() + api.deviceHardware = { + fetchStarted.complete(Unit) + gate.await() + listOf(knownHardware) + } coroutineScope { val first = async { repository.getDeviceHardwareByModel(hwModel = 37) } - val second = async { repository.getDeviceHardwareByModel(hwModel = 37) } - api.responseGate!!.complete(Unit) - awaitAll(first, second) + withTimeout(5.seconds) { fetchStarted.await() } + repository.getDeviceHardwareByModel(hwModel = 37) + gate.complete(Unit) + first.await() } - assertEquals(1, api.hardwareCalls, "concurrent callers must share one fetch") + assertEquals(1, api.deviceHardwareCalls, "concurrent callers must share one fetch") assertEquals(1, links.reconcileCalls, "concurrent callers must share one reconcile") } @@ -210,7 +179,7 @@ class DeviceHardwareRepositoryImplTest { links.reconcileFailure = null assertNull(repository.getDeviceHardwareByModel(hwModel = 37).getOrThrow()) - assertEquals(1, api.hardwareCalls, "catalog success must gate further hardware fetches") + assertEquals(1, api.deviceHardwareCalls, "catalog success must gate further hardware fetches") assertEquals(1, links.reconcileCalls, "retry outside TTL must not retry reconcile either") } @@ -219,23 +188,23 @@ class DeviceHardwareRepositoryImplTest { repository.getDeviceHardwareByModel(hwModel = 37).getOrThrow() repository.getDeviceHardwareByModel(hwModel = 37, forceRefresh = true).getOrThrow() - assertEquals(2, api.hardwareCalls) + assertEquals(2, api.deviceHardwareCalls) assertEquals(2, links.reconcileCalls) } @Test fun emptyForcedRefreshPreservesPreviouslyCachedRemoteCatalog() = runBlocking { - api.response = listOf(remoteOnlyHardware) + api.deviceHardware = { listOf(remoteOnlyHardware) } val initial = repository.getDeviceHardwareByModel(hwModel = remoteOnlyHardware.hwModel).getOrThrow() assertEquals(remoteOnlyHardware.displayName, initial?.displayName) assertNull(repository.getDeviceHardwareByModel(hwModel = knownHardware.hwModel).getOrThrow()) - api.response = emptyList() + api.deviceHardware = { emptyList() } val afterEmptyRefresh = repository.getDeviceHardwareByModel(hwModel = remoteOnlyHardware.hwModel, forceRefresh = true).getOrThrow() assertEquals(remoteOnlyHardware.displayName, afterEmptyRefresh?.displayName) - assertEquals(2, api.hardwareCalls) + assertEquals(2, api.deviceHardwareCalls) assertEquals(2, links.reconcileCalls) } @@ -266,8 +235,8 @@ class DeviceHardwareRepositoryImplTest { /** Rebuilds the repository around an nRF fixture so SoftDevice resolution can be exercised. */ private fun nrfRepository(quirks: String?): DeviceHardwareRepositoryImpl { - api = FakeApiService(listOf(nrfHardware)) - assetReader = FakeBundledAssetReader(listOf(nrfHardware), json).apply { quirksJson = quirks } + api = FakeApiService(deviceHardware = { listOf(nrfHardware) }) + assetReader = bundledAssets(listOf(nrfHardware), quirks) return DeviceHardwareRepositoryImpl( remoteDataSource = DeviceHardwareRemoteDataSource(api, dispatchers), localDataSource = DeviceHardwareLocalDataSource(databaseProvider), diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt index 0c63ea5b03..509886520e 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt @@ -19,22 +19,11 @@ package org.meshtastic.core.data.repository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json -import okio.Buffer -import okio.Source -import org.meshtastic.core.data.datasource.BundledAssetReader import org.meshtastic.core.data.datasource.DeviceLinkLocalDataSource import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.BootloaderOtaQuirksResponse -import org.meshtastic.core.model.EventFirmwareResponse -import org.meshtastic.core.model.FirmwareReleaseManifest -import org.meshtastic.core.model.MaintenanceUf2Manifest -import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkDeviceLink import org.meshtastic.core.model.NetworkDeviceLinksResponse -import org.meshtastic.core.model.NetworkFirmwareNightly -import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.network.DeviceLinksRemoteDataSource -import org.meshtastic.core.network.service.ApiService import org.meshtastic.core.testing.FakeDatabaseProvider import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -44,35 +33,6 @@ import kotlin.test.assertTrue class DeviceLinkRepositoryImplTest { - /** Only [getDeviceLinks] is exercised; the other endpoints are never called by the link repository. */ - private class FakeApiService(var response: NetworkDeviceLinksResponse) : ApiService { - override suspend fun getDeviceHardware(): List = error("unused") - - override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = response - - override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused") - - override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = error("unused") - - override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused") - - override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused") - - override suspend fun getBootloaderOtaQuirks(): BootloaderOtaQuirksResponse = error("unused") - - override suspend fun getMaintenanceUf2Manifest(): MaintenanceUf2Manifest = error("unused") - } - - /** Serves only `device_links.json`, serializing the current [links] so the repo seeds via the real decode path. */ - private class FakeBundledAssetReader(var links: List, private val json: Json) : - BundledAssetReader { - override fun open(name: String): Source? { - if (name != "device_links.json") return null - val bytes = json.encodeToString(NetworkDeviceLinksResponse(links = links)).encodeToByteArray() - return Buffer().write(bytes) - } - } - private val json = Json { ignoreUnknownKeys = true } // Real dispatchers + runBlocking (per test), NOT runTest/UnconfinedTestDispatcher. reconcile() guards its network @@ -103,12 +63,21 @@ class DeviceLinkRepositoryImplTest { regions = regions, ) + /** Seeds `device_links.json` through the real decode path. */ + private fun seedLinks(links: List) = + seed.put("device_links.json", NetworkDeviceLinksResponse(links = links), json) + + private fun serveLinks(vararg links: NetworkDeviceLink) { + api.deviceLinks = { NetworkDeviceLinksResponse(links = links.toList()) } + } + @BeforeTest fun setup() { dbProvider = FakeDatabaseProvider() local = DeviceLinkLocalDataSource(dbProvider) - api = FakeApiService(NetworkDeviceLinksResponse()) - seed = FakeBundledAssetReader(emptyList(), json) + api = FakeApiService(deviceLinks = { NetworkDeviceLinksResponse() }) + seed = FakeBundledAssetReader() + seedLinks(emptyList()) repository = DeviceLinkRepositoryImpl( remoteDataSource = DeviceLinksRemoteDataSource(api, dispatchers), @@ -123,8 +92,8 @@ class DeviceLinkRepositoryImplTest { @Test fun seedsFromBundledJsonWhenEmptyAndDropsInternalLinks() = runBlocking { - seed.links = - listOf(link("rak4631", targets = listOf("rak4631")), link("github", type = NetworkDeviceLink.TYPE_INTERNAL)) + val vendor = link("rak4631", targets = listOf("rak4631")) + seedLinks(listOf(vendor, link("github", type = NetworkDeviceLink.TYPE_INTERNAL))) repository.ensureImported() assertEquals(setOf("rak4631"), local.getAll().map { it.shortCode }.toSet()) @@ -132,38 +101,35 @@ class DeviceLinkRepositoryImplTest { @Test fun ensureImportedSeedsOnlyWhenEmpty() = runBlocking { - seed.links = listOf(link("rak4631", targets = listOf("rak4631"))) + val first = listOf(link("rak4631", targets = listOf("rak4631"))) + seedLinks(first) repository.ensureImported() assertEquals(1, local.count()) // A larger snapshot must NOT re-seed once the table is populated. - seed.links = seed.links + link("heltec-v3", targets = listOf("heltec-v3")) + seedLinks(first + link("heltec-v3", targets = listOf("heltec-v3"))) repository.ensureImported() assertEquals(1, local.count()) } @Test fun getLinksForTargetFiltersByTargetAndRegionVendorFirst() = runBlocking { - api.response = - NetworkDeviceLinksResponse( - links = - listOf( - link( - "rokland-rak4631", - type = NetworkDeviceLink.TYPE_MARKETPLACE, - targets = listOf("rak4631"), - regions = listOf("US"), - ), - link("rak4631", targets = listOf("rak4631")), - link("heltec-v3", targets = listOf("heltec-v3")), - link( - "de-only", - type = NetworkDeviceLink.TYPE_MARKETPLACE, - targets = listOf("rak4631"), - regions = listOf("DE"), - ), - ), - ) + serveLinks( + link( + "rokland-rak4631", + type = NetworkDeviceLink.TYPE_MARKETPLACE, + targets = listOf("rak4631"), + regions = listOf("US"), + ), + link("rak4631", targets = listOf("rak4631")), + link("heltec-v3", targets = listOf("heltec-v3")), + link( + "de-only", + type = NetworkDeviceLink.TYPE_MARKETPLACE, + targets = listOf("rak4631"), + regions = listOf("DE"), + ), + ) repository.reconcile() val links = repository.getLinksForTarget("rak4631", regionCode = "US") @@ -175,11 +141,7 @@ class DeviceLinkRepositoryImplTest { @Test fun worldwideLinksShowRegardlessOfRegion() = runBlocking { - api.response = - NetworkDeviceLinksResponse( - links = - listOf(link("ww", type = NetworkDeviceLink.TYPE_MARKETPLACE, targets = listOf("t"), regions = null)), - ) + serveLinks(link("ww", type = NetworkDeviceLink.TYPE_MARKETPLACE, targets = listOf("t"), regions = null)) repository.reconcile() assertEquals(listOf("ww"), repository.getLinksForTarget("t", regionCode = "ZZ").map { it.shortCode }) @@ -187,25 +149,22 @@ class DeviceLinkRepositoryImplTest { @Test fun reconcilePrunesShortCodesNoLongerInCatalog() = runBlocking { - api.response = - NetworkDeviceLinksResponse( - links = listOf(link("a", targets = listOf("t")), link("b", targets = listOf("t"))), - ) + serveLinks(link("a", targets = listOf("t")), link("b", targets = listOf("t"))) repository.reconcile() assertEquals(2, local.count()) - api.response = NetworkDeviceLinksResponse(links = listOf(link("a", targets = listOf("t")))) + serveLinks(link("a", targets = listOf("t"))) repository.reconcile() assertEquals(setOf("a"), local.getAll().map { it.shortCode }.toSet()) } @Test fun emptyResponseLeavesCacheUntouched() = runBlocking { - api.response = NetworkDeviceLinksResponse(links = listOf(link("a", targets = listOf("t")))) + serveLinks(link("a", targets = listOf("t"))) repository.reconcile() assertEquals(1, local.count()) - api.response = NetworkDeviceLinksResponse(links = emptyList()) + serveLinks() repository.reconcile() assertEquals(1, local.count()) } diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt index 090ac29343..a853219635 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt @@ -21,26 +21,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json -import okio.Buffer -import okio.Source -import org.meshtastic.core.data.datasource.BundledAssetReader import org.meshtastic.core.data.datasource.EventFirmwareEditionLocalDataSource import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.BootloaderOtaQuirksResponse import org.meshtastic.core.model.EventFirmwareBuild import org.meshtastic.core.model.EventFirmwareEdition import org.meshtastic.core.model.EventFirmwareFonts import org.meshtastic.core.model.EventFirmwareResponse import org.meshtastic.core.model.EventFirmwareTheme import org.meshtastic.core.model.EventFirmwareThemeColors -import org.meshtastic.core.model.FirmwareReleaseManifest -import org.meshtastic.core.model.MaintenanceUf2Manifest -import org.meshtastic.core.model.NetworkDeviceHardware -import org.meshtastic.core.model.NetworkDeviceLinksResponse -import org.meshtastic.core.model.NetworkFirmwareNightly -import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.network.EventFirmwareRemoteDataSource -import org.meshtastic.core.network.service.ApiService import org.meshtastic.core.testing.FakeDatabaseProvider import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -52,44 +41,6 @@ import kotlin.time.Duration.Companion.seconds class EventFirmwareRepositoryImplTest { - /** Only [getEventFirmware] is exercised; the other endpoints are never called by this repository. */ - private class FakeApiService(var response: EventFirmwareResponse) : ApiService { - var eventFirmwareCalls = 0 - private set - - override suspend fun getDeviceHardware(): List = error("unused") - - override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused") - - override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused") - - override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = error("unused") - - override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused") - - override suspend fun getEventFirmware(): EventFirmwareResponse { - eventFirmwareCalls++ - return response - } - - override suspend fun getBootloaderOtaQuirks(): BootloaderOtaQuirksResponse = error("unused") - - override suspend fun getMaintenanceUf2Manifest(): MaintenanceUf2Manifest = error("unused") - } - - /** Serves only `event_firmware.json`, serializing [editions] so the repo decodes via the real path. */ - private class FakeBundledAssetReader( - var editions: List, - private val json: Json, - var present: Boolean = true, - ) : BundledAssetReader { - override fun open(name: String): Source? { - if (name != "event_firmware.json" || !present) return null - val bytes = json.encodeToString(EventFirmwareResponse(editions = editions)).encodeToByteArray() - return Buffer().write(bytes) - } - } - private val json = Json { ignoreUnknownKeys = true } // Real dispatchers + runBlocking (per test), NOT runTest/UnconfinedTestDispatcher — see the comment in @@ -106,12 +57,21 @@ class EventFirmwareRepositoryImplTest { private fun edition(name: String) = EventFirmwareEdition(edition = name, displayName = name.lowercase(), welcomeMessage = "hi $name") + /** Seeds `event_firmware.json` through the real decode path. */ + private fun seedEditions(editions: List) = + seed.put(EVENT_ASSET, EventFirmwareResponse(editions = editions), json) + + private fun serveEditions(editions: List) { + api.eventFirmware = { EventFirmwareResponse(editions = editions) } + } + @BeforeTest fun setup() { dbProvider = FakeDatabaseProvider() local = EventFirmwareEditionLocalDataSource(dbProvider, dispatchers) - api = FakeApiService(EventFirmwareResponse()) - seed = FakeBundledAssetReader(emptyList(), json) + api = FakeApiService(eventFirmware = { EventFirmwareResponse() }) + seed = FakeBundledAssetReader() + seedEditions(emptyList()) repository = EventFirmwareRepositoryImpl( remoteDataSource = EventFirmwareRemoteDataSource(api, dispatchers), @@ -126,7 +86,7 @@ class EventFirmwareRepositoryImplTest { @Test fun getEditionReturnsMatchingRecordByEnumName() = runBlocking { - seed.editions = listOf(edition("HAMVENTION"), edition("DEFCON")) + seedEditions(listOf(edition("HAMVENTION"), edition("DEFCON"))) assertEquals("hamvention", repository.getEdition("HAMVENTION")?.displayName) assertEquals("hi DEFCON", repository.getEdition("DEFCON")?.welcomeMessage) @@ -134,7 +94,7 @@ class EventFirmwareRepositoryImplTest { @Test fun getEditionReturnsNullForUnknownEdition() = runBlocking { - seed.editions = listOf(edition("HAMVENTION")) + seedEditions(listOf(edition("HAMVENTION"))) assertNull(repository.getEdition("VANILLA")) } @@ -145,8 +105,8 @@ class EventFirmwareRepositoryImplTest { // must reach the UI when the refresh lands, without the caller re-subscribing. The new value therefore has to // arrive *through the refresh path*; writing it into the cache by hand would still pass if observeEdition // stopped refreshing at all. - seed.editions = listOf(edition("HAMVENTION")) - api.response = EventFirmwareResponse(editions = listOf(edition("HAMVENTION"), edition("DEFCON"))) + seedEditions(listOf(edition("HAMVENTION"))) + serveEditions(listOf(edition("HAMVENTION"), edition("DEFCON"))) repository.observeEdition("DEFCON").test(timeout = EMISSION_TIMEOUT) { // The first item is null only if the refresh is still in flight when collection starts; under @@ -164,34 +124,35 @@ class EventFirmwareRepositoryImplTest { @Test fun observeEditionEmitsNullForUnknownEdition() = runBlocking { - seed.editions = listOf(edition("HAMVENTION")) + seedEditions(listOf(edition("HAMVENTION"))) assertNull(repository.observeEdition("VANILLA").first()) } @Test fun absentAssetYieldsNullWithoutCrashing() = runBlocking { - seed.present = false + seed.remove(EVENT_ASSET) assertNull(repository.getEdition("HAMVENTION")) } @Test fun seedsFromBundledJsonOnlyWhenTableEmpty() = runBlocking { - seed.editions = listOf(edition("HAMVENTION")) + val first = listOf(edition("HAMVENTION")) + seedEditions(first) repository.getEdition("HAMVENTION") assertEquals(1, local.count()) // A larger snapshot must NOT re-seed once the table is populated. - seed.editions = seed.editions + edition("DEFCON") + seedEditions(first + edition("DEFCON")) repository.getEdition("DEFCON") assertEquals(1, local.count()) } @Test fun networkRefreshReplacesBundledSnapshot() = runBlocking { - seed.editions = listOf(edition("HAMVENTION")) - api.response = EventFirmwareResponse(editions = listOf(edition("DEFCON"))) + seedEditions(listOf(edition("HAMVENTION"))) + serveEditions(listOf(edition("DEFCON"))) // First call is always stale (never refreshed yet), so it triggers + awaits the network fetch. assertNull(repository.getEdition("HAMVENTION")) @@ -200,8 +161,8 @@ class EventFirmwareRepositoryImplTest { @Test fun emptyNetworkResponseLeavesBundledSnapshotInPlace() = runBlocking { - seed.editions = listOf(edition("HAMVENTION")) - api.response = EventFirmwareResponse(editions = emptyList()) + seedEditions(listOf(edition("HAMVENTION"))) + serveEditions(emptyList()) assertEquals("hamvention", repository.getEdition("HAMVENTION")?.displayName) } @@ -210,8 +171,8 @@ class EventFirmwareRepositoryImplTest { fun failedRefreshDoesNotRetryOnEveryCall() = runBlocking { // Empty response never advances the success timestamp; without the retry cooldown the second call would // re-enter the stale branch and fetch again. The cooldown (minutes) means a second immediate call skips it. - seed.editions = listOf(edition("HAMVENTION")) - api.response = EventFirmwareResponse(editions = emptyList()) + seedEditions(listOf(edition("HAMVENTION"))) + serveEditions(emptyList()) repository.getEdition("HAMVENTION") repository.getEdition("HAMVENTION") @@ -221,7 +182,7 @@ class EventFirmwareRepositoryImplTest { @Test fun v2FieldsRoundTripThroughCache() = runBlocking { - seed.editions = + seedEditions( listOf( EventFirmwareEdition( edition = "HAMVENTION", @@ -238,7 +199,8 @@ class EventFirmwareRepositoryImplTest { ), firmware = EventFirmwareBuild(version = "2.7.23.07741e6", zipUrl = "https://example/f.zip"), ), - ) + ), + ) val got = repository.getEdition("HAMVENTION")!! @@ -255,11 +217,11 @@ class EventFirmwareRepositoryImplTest { @Test fun persistsAcrossRepositoryInstancesBackedByTheSameDb() = runBlocking { - seed.editions = listOf(edition("HAMVENTION")) + seedEditions(listOf(edition("HAMVENTION"))) repository.getEdition("HAMVENTION") // A fresh repository instance (e.g. after a process restart) reads the same DB without re-seeding. - seed.editions = emptyList() + seedEditions(emptyList()) val restarted = EventFirmwareRepositoryImpl( remoteDataSource = EventFirmwareRemoteDataSource(api, dispatchers), @@ -275,5 +237,7 @@ class EventFirmwareRepositoryImplTest { private companion object { /** Room's invalidation tracker plus the fake network round-trip; generous, since it only bounds a failure. */ private val EMISSION_TIMEOUT = 10.seconds + + private const val EVENT_ASSET = "event_firmware.json" } } diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FakeApiService.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FakeApiService.kt new file mode 100644 index 0000000000..900f6f4608 --- /dev/null +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FakeApiService.kt @@ -0,0 +1,110 @@ +/* + * 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.data.repository + +import org.meshtastic.core.model.BootloaderOtaQuirksResponse +import org.meshtastic.core.model.EventFirmwareResponse +import org.meshtastic.core.model.FirmwareReleaseManifest +import org.meshtastic.core.model.MaintenanceUf2Manifest +import org.meshtastic.core.model.NetworkDeviceHardware +import org.meshtastic.core.model.NetworkDeviceLinksResponse +import org.meshtastic.core.model.NetworkFirmwareNightly +import org.meshtastic.core.model.NetworkFirmwareReleases +import org.meshtastic.core.network.service.ApiService + +/** + * [ApiService] whose endpoints answer with whatever a test sets. An endpoint left unset fails the call that reaches it, + * and every call is counted before it runs, so a test can wait on or assert the path it expects. + */ +internal class FakeApiService( + var deviceHardware: suspend () -> List = { unused() }, + var deviceLinks: suspend () -> NetworkDeviceLinksResponse = { unused() }, + var firmwareReleases: suspend () -> NetworkFirmwareReleases = { unused() }, + var firmwareReleaseManifest: suspend (manifestUrl: String) -> FirmwareReleaseManifest = { unused() }, + var nightlyFirmware: suspend () -> NetworkFirmwareNightly? = { unused() }, + var eventFirmware: suspend () -> EventFirmwareResponse = { unused() }, + var bootloaderOtaQuirks: suspend () -> BootloaderOtaQuirksResponse = { unused() }, + var maintenanceUf2Manifest: suspend () -> MaintenanceUf2Manifest = { unused() }, +) : ApiService { + var deviceHardwareCalls = 0 + private set + + var deviceLinksCalls = 0 + private set + + var firmwareReleasesCalls = 0 + private set + + var firmwareReleaseManifestCalls = 0 + private set + + var nightlyFirmwareCalls = 0 + private set + + var eventFirmwareCalls = 0 + private set + + var bootloaderOtaQuirksCalls = 0 + private set + + var maintenanceUf2ManifestCalls = 0 + private set + + override suspend fun getDeviceHardware(): List { + deviceHardwareCalls++ + return deviceHardware() + } + + override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse { + deviceLinksCalls++ + return deviceLinks() + } + + override suspend fun getFirmwareReleases(): NetworkFirmwareReleases { + firmwareReleasesCalls++ + return firmwareReleases() + } + + override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest { + firmwareReleaseManifestCalls++ + return firmwareReleaseManifest(manifestUrl) + } + + override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? { + nightlyFirmwareCalls++ + return nightlyFirmware() + } + + override suspend fun getEventFirmware(): EventFirmwareResponse { + eventFirmwareCalls++ + return eventFirmware() + } + + override suspend fun getBootloaderOtaQuirks(): BootloaderOtaQuirksResponse { + bootloaderOtaQuirksCalls++ + return bootloaderOtaQuirks() + } + + override suspend fun getMaintenanceUf2Manifest(): MaintenanceUf2Manifest { + maintenanceUf2ManifestCalls++ + return maintenanceUf2Manifest() + } + + private companion object { + fun unused(): Nothing = error("endpoint not configured for this test") + } +} diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FakeBundledAssetReader.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FakeBundledAssetReader.kt new file mode 100644 index 0000000000..c77495fee8 --- /dev/null +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FakeBundledAssetReader.kt @@ -0,0 +1,48 @@ +/* + * 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.data.repository + +import kotlinx.serialization.json.Json +import okio.Buffer +import okio.Source +import org.meshtastic.core.data.datasource.BundledAssetReader + +/** [BundledAssetReader] over in-memory asset bodies keyed by file name; a name with no body is an absent asset. */ +internal class FakeBundledAssetReader : BundledAssetReader { + private val assets = mutableMapOf() + + /** How many reads throw before one succeeds, as an unreadable asset would. */ + var failuresBeforeSuccess = 0 + + fun put(name: String, body: String) { + assets[name] = body + } + + inline fun put(name: String, value: T, json: Json) = put(name, json.encodeToString(value)) + + fun remove(name: String) { + assets.remove(name) + } + + override fun open(name: String): Source? { + if (failuresBeforeSuccess > 0) { + failuresBeforeSuccess -= 1 + error("Bundled asset read failed") + } + return assets[name]?.let { Buffer().writeUtf8(it) } + } +} diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt index ce5b99394d..b35840ec14 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt @@ -25,27 +25,18 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield import kotlinx.serialization.json.Json -import okio.Buffer -import okio.Source -import org.meshtastic.core.data.datasource.BundledAssetReader import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource import org.meshtastic.core.database.entity.FirmwareReleaseEntity import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.BootloaderOtaQuirksResponse -import org.meshtastic.core.model.EventFirmwareResponse import org.meshtastic.core.model.FirmwareRelease import org.meshtastic.core.model.FirmwareReleaseManifest import org.meshtastic.core.model.FirmwareReleaseType import org.meshtastic.core.model.FirmwareTarget -import org.meshtastic.core.model.MaintenanceUf2Manifest -import org.meshtastic.core.model.NetworkDeviceHardware -import org.meshtastic.core.model.NetworkDeviceLinksResponse import org.meshtastic.core.model.NetworkFirmwareNightly import org.meshtastic.core.model.NetworkFirmwareRelease import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.model.Releases import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource -import org.meshtastic.core.network.service.ApiService import org.meshtastic.core.testing.FakeDatabaseProvider import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -55,60 +46,6 @@ import kotlin.test.assertTrue class FirmwareReleaseRepositoryImplTest { - /** Only the firmware endpoints are exercised; the others are never called by this repository. */ - private class FakeApiService(var response: NetworkFirmwareReleases) : ApiService { - var nightly: NetworkFirmwareNightly? = null - var nightlyUnreachable = false - var manifest: FirmwareReleaseManifest? = null - var manifestCalls = 0 - var firmwareCalls = 0 - - /** When set, [getFirmwareReleases] hangs until completed — simulates a slow/unreachable API. */ - var responseGate: CompletableDeferred? = null - - override suspend fun getDeviceHardware(): List = error("unused") - - override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused") - - override suspend fun getFirmwareReleases(): NetworkFirmwareReleases { - firmwareCalls++ - responseGate?.await() - return response - } - - override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest { - manifestCalls++ - return checkNotNull(manifest) { "manifest not configured" } - } - - override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? { - if (nightlyUnreachable) error("nightly index unreachable") - return nightly - } - - override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused") - - override suspend fun getBootloaderOtaQuirks(): BootloaderOtaQuirksResponse = error("unused") - - override suspend fun getMaintenanceUf2Manifest(): MaintenanceUf2Manifest = error("unused") - } - - /** Serves `firmware_releases.json` from [bundled] via the real decode path, or nothing when null. */ - private class FakeBundledAssetReader(var bundled: Releases? = null) : BundledAssetReader { - var failuresBeforeSuccess = 0 - - override fun open(name: String): Source? { - if (failuresBeforeSuccess > 0) { - failuresBeforeSuccess -= 1 - error("Bundled asset read failed") - } - val releases = bundled ?: return null - if (name != "firmware_releases.json") return null - val bytes = Json.encodeToString(NetworkFirmwareReleases(releases = releases)).encodeToByteArray() - return Buffer().write(bytes) - } - } - // Real dispatchers + runBlocking, NOT runTest — see DeviceLinkRepositoryImplTest for why (Room + virtual time). private val unconfined = Dispatchers.Unconfined private val dispatchers = CoroutineDispatchers(main = unconfined, io = unconfined, default = unconfined) @@ -127,10 +64,19 @@ class FirmwareReleaseRepositoryImplTest { private fun staleRow(id: String, type: FirmwareReleaseType) = FirmwareReleaseEntity(id = id, title = id, releaseType = type, lastUpdated = 0) + /** Seeds `firmware_releases.json` through the real decode path. */ + private fun seedBundled(releases: Releases) = + seed.put("firmware_releases.json", NetworkFirmwareReleases(releases = releases), Json) + + private fun serveReleases(releases: NetworkFirmwareReleases) { + api.firmwareReleases = { releases } + } + @BeforeTest fun setup() { dbProvider = FakeDatabaseProvider() - api = FakeApiService(NetworkFirmwareReleases()) + // No nightly published unless a test says otherwise, the host's 404. + api = FakeApiService(firmwareReleases = { NetworkFirmwareReleases() }, nightlyFirmware = { null }) seed = FakeBundledAssetReader() repository = FirmwareReleaseRepositoryImpl( @@ -151,11 +97,12 @@ class FirmwareReleaseRepositoryImplTest { dao.insert(staleRow("v2.7.26.54e0d8d", FirmwareReleaseType.ALPHA)) dao.insert(staleRow("v9.9.9.deadbee", FirmwareReleaseType.ALPHA)) // Upstream promoted 2.7.26 to stable and dropped 9.9.9. - api.response = + serveReleases( NetworkFirmwareReleases( releases = Releases(stable = listOf(release("v2.7.26.54e0d8d")), alpha = listOf(release("v2.7.25.104df5f"))), - ) + ), + ) val stableEmissions = repository.stableRelease.toList() @@ -171,22 +118,23 @@ class FirmwareReleaseRepositoryImplTest { @Test fun `manifest board targets are fetched once and cached by release URL`() = runBlocking { val release = FirmwareRelease(id = "v2.8.0", zipUrl = "https://example.com/manifest") - api.manifest = + api.firmwareReleaseManifest = { FirmwareReleaseManifest( version = "2.8.0", targets = listOf(FirmwareTarget(board = "tbeam-s3-core", platform = "esp32s3")), ) + } assertEquals(setOf("tbeam-s3-core"), repository.getManifestTargets(release)) assertEquals(setOf("tbeam-s3-core"), repository.getManifestTargets(release)) - assertEquals(1, api.manifestCalls) + assertEquals(1, api.firmwareReleaseManifestCalls) } @Test fun refreshLeavesLocalRowsUntouched() = runBlocking { dao.insert(staleRow("v2.7.15.567b8ea", FirmwareReleaseType.STABLE)) dao.insert(staleRow("local-import", FirmwareReleaseType.LOCAL)) - api.response = NetworkFirmwareReleases(releases = Releases(stable = listOf(release("v2.7.26.54e0d8d")))) + serveReleases(NetworkFirmwareReleases(releases = Releases(stable = listOf(release("v2.7.26.54e0d8d"))))) repository.stableRelease.toList() @@ -196,7 +144,7 @@ class FirmwareReleaseRepositoryImplTest { @Test fun emptyResponseLeavesCacheUntouched() = runBlocking { dao.insert(staleRow("v2.7.15.567b8ea", FirmwareReleaseType.STABLE)) - api.response = NetworkFirmwareReleases() + serveReleases(NetworkFirmwareReleases()) val emissions = repository.stableRelease.toList() @@ -206,8 +154,7 @@ class FirmwareReleaseRepositoryImplTest { @Test fun emptyCacheSeedsFromBundledSnapshot() = runBlocking { - seed.bundled = Releases(stable = listOf(release("v2.7.15.567b8ea")), alpha = listOf(release("v2.7.25.104df5f"))) - api.response = NetworkFirmwareReleases() + seedBundled(Releases(stable = listOf(release("v2.7.15.567b8ea")), alpha = listOf(release("v2.7.25.104df5f")))) val emissions = repository.stableRelease.toList() @@ -219,8 +166,7 @@ class FirmwareReleaseRepositoryImplTest { fun failedBundledDecodeIsNotRetried() = runBlocking { // A broken bundled asset is a permanent failure — retrying on every collection just burns I/O. seed.failuresBeforeSuccess = 1 - seed.bundled = Releases(stable = listOf(release("v2.7.15.567b8ea"))) - api.response = NetworkFirmwareReleases() + seedBundled(Releases(stable = listOf(release("v2.7.15.567b8ea")))) val initialEmissions = repository.stableRelease.toList() // Subsequent collection must NOT retry the decode (the second call would succeed if it did). @@ -235,8 +181,7 @@ class FirmwareReleaseRepositoryImplTest { // Reproduces the original bug: the active Room DB switches per selected device. A process-wide // seed gate set during the first collection (against the default DB) skipped seeding the newly // activated device DB, leaving the firmware picker empty. - seed.bundled = Releases(stable = listOf(release("v2.7.15.567b8ea"))) - api.response = NetworkFirmwareReleases() + seedBundled(Releases(stable = listOf(release("v2.7.15.567b8ea")))) val firstEmissions = repository.stableRelease.toList() assertEquals("v2.7.15.567b8ea", firstEmissions.first()?.id, "first collection seeds DB-A") @@ -256,7 +201,7 @@ class FirmwareReleaseRepositoryImplTest { fun newerBundledSnapshotReplacesOlderCache() = runBlocking { // App update ships a fresher bundle than what a network-starved user has cached. dao.insert(FirmwareReleaseEntity(id = "v2.7.15.567b8ea", releaseType = FirmwareReleaseType.STABLE)) - seed.bundled = Releases(stable = listOf(release("v2.7.26.54e0d8d"))) + seedBundled(Releases(stable = listOf(release("v2.7.26.54e0d8d")))) val emissions = repository.stableRelease.toList() @@ -268,7 +213,7 @@ class FirmwareReleaseRepositoryImplTest { // Cache has data for both types; the bundle ships only a newer stable list. dao.insert(FirmwareReleaseEntity(id = "v2.7.15.567b8ea", releaseType = FirmwareReleaseType.STABLE)) dao.insert(FirmwareReleaseEntity(id = "v2.7.25.104df5f", releaseType = FirmwareReleaseType.ALPHA)) - seed.bundled = Releases(stable = listOf(release("v2.7.26.54e0d8d"))) + seedBundled(Releases(stable = listOf(release("v2.7.26.54e0d8d")))) val emissions = repository.stableRelease.toList() @@ -294,7 +239,7 @@ class FirmwareReleaseRepositoryImplTest { @Test fun nightlyFlowMapsPublishedIndex() = runBlocking { - api.nightly = NetworkFirmwareNightly(version = "2.8.0.f52e2ea", commit = "f52e2ea8efa096a") + api.nightlyFirmware = { NetworkFirmwareNightly(version = "2.8.0.f52e2ea", commit = "f52e2ea8efa096a") } val emissions = repository.nightlyRelease.toList() @@ -309,7 +254,7 @@ class FirmwareReleaseRepositoryImplTest { fun unpublishedNightlyClearsStaleRow() = runBlocking { // A nightly was cached, then unpublished upstream (index.json now 404s). dao.insert(staleRow("v2.8.0.f52e2ea", FirmwareReleaseType.NIGHTLY)) - api.nightly = null + api.nightlyFirmware = { null } val emissions = repository.nightlyRelease.toList() @@ -321,7 +266,7 @@ class FirmwareReleaseRepositoryImplTest { @Test fun unreachableNightlyIndexLeavesCacheUntouched() = runBlocking { dao.insert(staleRow("v2.8.0.f52e2ea", FirmwareReleaseType.NIGHTLY)) - api.nightlyUnreachable = true + api.nightlyFirmware = { error("nightly index unreachable") } val emissions = repository.nightlyRelease.toList() @@ -345,11 +290,13 @@ class FirmwareReleaseRepositoryImplTest { dao.insert(staleRow("v2.7.26.54e0d8d", FirmwareReleaseType.STABLE)) dao.insert(staleRow("v2.7.27.abc1234", FirmwareReleaseType.ALPHA)) val gate = CompletableDeferred() - api.responseGate = gate - api.response = NetworkFirmwareReleases() + api.firmwareReleases = { + gate.await() + NetworkFirmwareReleases() + } val stableCollector = launch { repository.stableRelease.collect {} } - withTimeout(2_000) { while (api.firmwareCalls == 0) yield() } // stable's refresh is now hanging on the API + withTimeout(2_000) { while (api.firmwareReleasesCalls == 0) yield() } // stable's refresh is now hanging val alphaCached = withTimeout(2_000) { repository.alphaRelease.first() } assertEquals("v2.7.27.abc1234", alphaCached?.id, "alpha cache emits while the stable refresh is in flight") @@ -363,32 +310,33 @@ class FirmwareReleaseRepositoryImplTest { dao.insert(staleRow("v2.7.26.54e0d8d", FirmwareReleaseType.STABLE)) dao.insert(staleRow("v2.7.27.abc1234", FirmwareReleaseType.ALPHA)) val gate = CompletableDeferred() - api.responseGate = gate // The response refreshes BOTH channels, so whichever collector reaches its fetch decision second either // joins the in-flight refresh (refresh still hanging on [gate]) or finds its rows already fresh (refresh // completed) — one network call in both interleavings. - api.response = + api.firmwareReleases = { + gate.await() NetworkFirmwareReleases( releases = Releases(stable = listOf(release("v2.7.28.def5678")), alpha = listOf(release("v2.7.29.fedcba9"))), ) + } val stableCollector = launch { repository.stableRelease.collect {} } val alphaCollector = launch { repository.alphaRelease.collect {} } - withTimeout(2_000) { while (api.firmwareCalls == 0) yield() } + withTimeout(2_000) { while (api.firmwareReleasesCalls == 0) yield() } gate.complete(Unit) stableCollector.join() alphaCollector.join() - assertEquals(1, api.firmwareCalls, "stable and alpha collectors share one refresh") + assertEquals(1, api.firmwareReleasesCalls, "stable and alpha collectors share one refresh") } @Test fun olderBundledSnapshotNeverRegressesCache() = runBlocking { // A successful network refresh left the cache newer than the (weekly) bundle. dao.insert(FirmwareReleaseEntity(id = "v2.8.0.abc1234", releaseType = FirmwareReleaseType.STABLE)) - seed.bundled = Releases(stable = listOf(release("v2.7.26.54e0d8d"))) + seedBundled(Releases(stable = listOf(release("v2.7.26.54e0d8d")))) val emissions = repository.stableRelease.toList() diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/MaintenanceUf2RepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/MaintenanceUf2RepositoryImplTest.kt index 88ee50e9e4..468b34ae83 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/MaintenanceUf2RepositoryImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/MaintenanceUf2RepositoryImplTest.kt @@ -19,24 +19,13 @@ package org.meshtastic.core.data.repository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json -import okio.Buffer -import okio.Source -import org.meshtastic.core.data.datasource.BundledAssetReader import org.meshtastic.core.data.datasource.MaintenanceUf2LocalDataSource import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.BootloaderOtaQuirksResponse import org.meshtastic.core.model.EraseImageEntry -import org.meshtastic.core.model.EventFirmwareResponse -import org.meshtastic.core.model.FirmwareReleaseManifest import org.meshtastic.core.model.MaintenanceUf2EraseSet import org.meshtastic.core.model.MaintenanceUf2Manifest -import org.meshtastic.core.model.NetworkDeviceHardware -import org.meshtastic.core.model.NetworkDeviceLinksResponse -import org.meshtastic.core.model.NetworkFirmwareNightly -import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.model.OtafixAssetEntry import org.meshtastic.core.network.MaintenanceUf2RemoteDataSource -import org.meshtastic.core.network.service.ApiService import org.meshtastic.core.testing.FakeDatabaseProvider import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -45,35 +34,6 @@ import kotlin.test.assertEquals class MaintenanceUf2RepositoryImplTest { - /** Only [getMaintenanceUf2Manifest] is exercised; the other endpoints are never called by this repository. */ - private class FakeApiService(var response: MaintenanceUf2Manifest) : ApiService { - override suspend fun getDeviceHardware(): List = error("unused") - - override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = error("unused") - - override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused") - - override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = error("unused") - - override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused") - - override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused") - - override suspend fun getBootloaderOtaQuirks(): BootloaderOtaQuirksResponse = error("unused") - - override suspend fun getMaintenanceUf2Manifest(): MaintenanceUf2Manifest = response - } - - /** Serves only `maintenance_uf2.json`, or nothing when [seed] is null (models the asset being absent). */ - private class FakeBundledAssetReader(var seed: MaintenanceUf2Manifest?, private val json: Json) : - BundledAssetReader { - override fun open(name: String): Source? { - if (name != "maintenance_uf2.json") return null - val current = seed ?: return null - return Buffer().write(json.encodeToString(current).encodeToByteArray()) - } - } - private val json = Json { ignoreUnknownKeys = true } // Real dispatchers + runBlocking, not runTest — matches BootloaderOtaQuirksRepositoryImplTest's rationale for @@ -108,12 +68,18 @@ class MaintenanceUf2RepositoryImplTest { ), ) + private fun seedManifest(manifest: MaintenanceUf2Manifest) = seed.put("maintenance_uf2.json", manifest, json) + + private fun serveManifest(manifest: MaintenanceUf2Manifest) { + api.maintenanceUf2Manifest = { manifest } + } + @BeforeTest fun setup() { dbProvider = FakeDatabaseProvider() local = MaintenanceUf2LocalDataSource(dbProvider, dispatchers) - api = FakeApiService(MaintenanceUf2Manifest()) - seed = FakeBundledAssetReader(null, json) + api = FakeApiService(maintenanceUf2Manifest = { MaintenanceUf2Manifest() }) + seed = FakeBundledAssetReader() repository = MaintenanceUf2RepositoryImpl( remoteDataSource = MaintenanceUf2RemoteDataSource(api, dispatchers), @@ -127,7 +93,7 @@ class MaintenanceUf2RepositoryImplTest { @Test fun getSnapshotSeedsFromBundledJsonWhenCacheIsEmpty() = runBlocking { - seed.seed = manifestWithBoard("HT-n5262") + seedManifest(manifestWithBoard("HT-n5262")) val snapshot = repository.getSnapshot() @@ -136,18 +102,19 @@ class MaintenanceUf2RepositoryImplTest { @Test fun getSnapshotSeedsOnlyWhenCacheIsEmpty() = runBlocking { - seed.seed = manifestWithBoard("HT-n5262") + seedManifest(manifestWithBoard("HT-n5262")) repository.getSnapshot() assertEquals(1, local.count()) // A changed bundled asset must NOT re-seed once the cache is populated. - seed.seed = + seedManifest( manifestWithBoard("HT-n5262").let { it.copy( otafixByBoardId = it.otafixByBoardId + ("OTHER-ID" to OtafixAssetEntry("other_board", "3".repeat(64))), ) - } + }, + ) val snapshot = repository.getSnapshot() assertEquals(1, local.count()) @@ -163,7 +130,7 @@ class MaintenanceUf2RepositoryImplTest { @Test fun reconcileUpdatesCacheFromTheNetwork() = runBlocking { - api.response = manifestWithErase("7.3.0") + serveManifest(manifestWithErase("7.3.0")) repository.reconcile() val snapshot = repository.getSnapshot() @@ -179,8 +146,9 @@ class MaintenanceUf2RepositoryImplTest { @Test fun aManifestWithoutTheBootloaderEraseEntryStillDecodes() = runBlocking { // Every manifest published before the entry existed, and every cache row written from one. - api.response = - manifestWithErase("7.3.0").copy(erase = manifestWithErase("7.3.0").erase?.copy(nrf52Bootloader = null)) + serveManifest( + manifestWithErase("7.3.0").copy(erase = manifestWithErase("7.3.0").erase?.copy(nrf52Bootloader = null)), + ) repository.reconcile() assertEquals(null, repository.getSnapshot().erase?.nrf52Bootloader) @@ -188,11 +156,11 @@ class MaintenanceUf2RepositoryImplTest { @Test fun emptyNetworkResponseLeavesCacheUntouched() = runBlocking { - api.response = manifestWithBoard("HT-n5262") + serveManifest(manifestWithBoard("HT-n5262")) repository.reconcile() assertEquals(1, local.count()) - api.response = MaintenanceUf2Manifest() + serveManifest(MaintenanceUf2Manifest()) repository.reconcile() assertEquals(1, local.count()) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt index e8d5868143..1ef805e14b 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt @@ -30,7 +30,6 @@ import kotlin.math.pow object AirQualityIndex { private const val NOWCAST_WINDOW_HOURS = 12 - private const val SECONDS_PER_HOUR = 3600L /** EPA requires the most recent hour plus at least 2 of the 3 most recent hours, or NowCast isn't reported. */ private const val MIN_VALID_HOURS = 2 @@ -49,7 +48,7 @@ object AirQualityIndex { val sums = DoubleArray(NOWCAST_WINDOW_HOURS) val counts = IntArray(NOWCAST_WINDOW_HOURS) for ((time, pm25) in readings) { - val hoursAgo = (nowEpochSeconds - time) / SECONDS_PER_HOUR + val hoursAgo = (nowEpochSeconds - time) / TimeConstants.SECONDS_PER_HOUR if (hoursAgo in 0 until NOWCAST_WINDOW_HOURS) { sums[hoursAgo.toInt()] += pm25 counts[hoursAgo.toInt()]++ @@ -87,7 +86,7 @@ object AirQualityIndex { * of readings per 12-hour window rather than quadratic over the whole time frame. */ fun nowCastAqiSeries(readings: List>): List { - val windowSeconds = NOWCAST_WINDOW_HOURS * SECONDS_PER_HOUR + val windowSeconds = NOWCAST_WINDOW_HOURS.toLong() * TimeConstants.SECONDS_PER_HOUR var start = 0 return readings.mapIndexed { index, (time, _) -> // Readings at or before this cutoff fall outside the point's own 12h window, so drop them from the front. diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt index e7e235656a..00f9c7bc5b 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt @@ -18,7 +18,6 @@ package org.meshtastic.core.model.util import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds /** * Returns a short string representing the time if it's within the last 24 hours, otherwise returns a combined short @@ -29,26 +28,6 @@ import kotlin.time.Duration.Companion.seconds */ expect fun getShortDateTime(time: Long): String -/** - * Formats a duration in seconds as a human-readable uptime string (e.g., "1d 2h 3m 4s"). - * - * @param seconds The duration in seconds. - * @return A formatted uptime string. - */ -fun formatUptime(seconds: Int): String { - val secs = seconds.toLong() - if (secs == 0L) return "0s" - return secs.seconds.toComponents { days, hours, minutes, s, _ -> - listOfNotNull( - "${days}d".takeIf { days > 0 }, - "${hours}h".takeIf { hours > 0 }, - "${minutes}m".takeIf { minutes > 0 }, - "${s}s".takeIf { s > 0 }, - ) - .joinToString(" ") - } -} - /** * Calculates the remaining mute time in days and hours. * diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt index 252297754a..d330410f80 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/GeoConstants.kt @@ -23,7 +23,4 @@ object GeoConstants { /** Multiplier to convert protobuf integer heading values (1e-5 degree units) to decimal degrees. */ const val HEADING_DEG = 1e-5 - - /** Mean radius of the Earth in meters, for haversine calculations. */ - const val EARTH_RADIUS_METERS = 6_371_000.0 } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeConstants.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeConstants.kt index c573927d23..25f005fc81 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeConstants.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeConstants.kt @@ -27,5 +27,7 @@ object TimeConstants { val TWO_DAYS = 2.days const val HOURS_PER_DAY = 24 + const val SECONDS_PER_MINUTE = 60 + const val SECONDS_PER_HOUR = 3600 const val MS_PER_SEC = 1000L } diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts index a7b08e1c49..788ec3b1c7 100644 --- a/core/resources/build.gradle.kts +++ b/core/resources/build.gradle.kts @@ -28,6 +28,8 @@ kotlin { } withHostTest { isIncludeAndroidResources = true } } + + sourceSets { commonMain.dependencies { implementation(projects.core.common) } } } compose.resources { diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 923cd7190d..f2463b36f0 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -197,7 +197,6 @@ Channel 8 Channel Features This Channel URL is invalid and can not be used - Channel Channel Name Channel URL ChUtil @@ -551,6 +550,12 @@ Download Download this area Duplicate Public Key Detected + + %1$s ago + %1$dd + %1$dh + %1$dm + %1$ds Dynamic Easily set up private mesh networks for secure and reliable communication in remote areas. Edit diff --git a/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/DurationText.kt b/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/DurationText.kt new file mode 100644 index 0000000000..400245d9ca --- /dev/null +++ b/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/DurationText.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.resources + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.DurationUnitLabels +import kotlin.time.DurationUnit +import org.meshtastic.core.common.util.formatDuration as formatDurationWith + +/** [totalSeconds] as translated duration text, e.g. `1d 2h 3m`; see [formatDurationWith] for [smallest]. */ +@Composable +fun formatDuration(totalSeconds: Long, smallest: DurationUnit = DurationUnit.SECONDS): String { + // Loaded without arguments on purpose: each template keeps its %1$d for formatDurationWith to fill. + val labels = + DurationUnitLabels( + days = stringResource(Res.string.duration_days_short), + hours = stringResource(Res.string.duration_hours_short), + minutes = stringResource(Res.string.duration_minutes_short), + seconds = stringResource(Res.string.duration_seconds_short), + ) + return formatDurationWith(totalSeconds, labels, smallest) +} + +/** The suspending counterpart of [formatDuration], for text built outside composition such as notifications. */ +suspend fun formatDurationSuspend(totalSeconds: Long, smallest: DurationUnit = DurationUnit.SECONDS): String { + val labels = + DurationUnitLabels( + days = getStringSuspend(Res.string.duration_days_short), + hours = getStringSuspend(Res.string.duration_hours_short), + minutes = getStringSuspend(Res.string.duration_minutes_short), + seconds = getStringSuspend(Res.string.duration_seconds_short), + ) + return formatDurationWith(totalSeconds, labels, smallest) +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/EncryptedPreferences.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/EncryptedPreferences.kt new file mode 100644 index 0000000000..bf1d2a859f --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/EncryptedPreferences.kt @@ -0,0 +1,46 @@ +/* + * 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 android.app.Application +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import co.touchlab.kermit.Logger + +/** + * Opens [fileName] as EncryptedSharedPreferences backed by an AES-256-GCM MasterKey (hardware keystore when available), + * or returns null when the keystore cannot be initialized. The key is not gated behind user authentication, so + * background work can read it. + */ +// androidx.security.crypto (MasterKey / EncryptedSharedPreferences) is deprecated by Google with no drop-in AndroidX +// replacement yet. Migrating encrypted storage is a separate, security-sensitive effort; suppress until a stable +// replacement (e.g. Tink) is adopted. +@Suppress("TooGenericExceptionCaught", "DEPRECATION") +internal fun openEncryptedPreferences(app: Application, fileName: String): SharedPreferences? = try { + val masterKey = MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() + EncryptedSharedPreferences.create( + app, + fileName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) +} catch (e: Exception) { + Logger.e(e) { "Failed to initialize encrypted preferences $fileName" } + null +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 7711138542..be2d9437c6 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -18,42 +18,18 @@ package org.meshtastic.core.service import android.app.Application import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import co.touchlab.kermit.Logger import org.koin.core.annotation.Single import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.repository.StoredPassphrase /** - * Encrypted per-device storage for lockdown passphrases. - * - * Uses EncryptedSharedPreferences backed by an AES-256-GCM MasterKey (hardware keystore when available). The key is - * intentionally NOT gated behind biometric authentication so that auto-unlock can run in the background without user - * interaction. + * Encrypted per-device storage for lockdown passphrases, in [openEncryptedPreferences]. The key is not gated behind + * biometric authentication so that auto-unlock can run in the background without user interaction. */ @Single(binds = [LockdownPassphraseStore::class]) class LockdownPassphraseStoreImpl(app: Application) : LockdownPassphraseStore { - // androidx.security.crypto (MasterKey / EncryptedSharedPreferences) is deprecated by Google with no - // drop-in AndroidX replacement yet. Migrating encrypted storage is a separate, security-sensitive - // effort; suppress until a stable replacement (e.g. Tink) is adopted. - @Suppress("TooGenericExceptionCaught", "DEPRECATION") - private val prefs: SharedPreferences? by lazy { - try { - val masterKey = MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - EncryptedSharedPreferences.create( - app, - PREFS_FILE_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } catch (e: Exception) { - Logger.e(e) { "Failed to initialize encrypted passphrase store" } - null - } - } + private val prefs: SharedPreferences? by lazy { openEncryptedPreferences(app, PREFS_FILE_NAME) } private fun requirePrefs(): SharedPreferences = prefs ?: error("Encrypted passphrase store unavailable") diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt index ef8ac4aa43..da3e6a9757 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt @@ -56,7 +56,6 @@ import org.meshtastic.core.model.Message import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.noiseFloorOrNull -import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI import org.meshtastic.core.repository.FirmwareUpdateProgress import org.meshtastic.core.repository.FirmwareUpdateStatusRepository @@ -77,6 +76,7 @@ import org.meshtastic.core.resources.discovery_scan_in_progress import org.meshtastic.core.resources.firmware_update_available import org.meshtastic.core.resources.firmware_update_in_progress import org.meshtastic.core.resources.firmware_update_notification_android +import org.meshtastic.core.resources.formatDurationSuspend import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.local_stats_bad import org.meshtastic.core.resources.local_stats_battery @@ -256,8 +256,7 @@ class MeshNotificationManagerImpl( */ internal suspend fun ensureChannels() = channelsMutex.withLock { if (channelsReady) return@withLock - val labels = - withTimeoutOrNull(CHANNEL_LABEL_TIMEOUT) { safeCatching { resolveChannelLabels() }.getOrNull() } + val labels = withTimeoutOrNull(CHANNEL_LABEL_TIMEOUT) { safeCatching { resolveChannelLabels() }.getOrNull() } val groups = NotificationChannelGroupSpec.entries.map { it.toGroup(labels?.groups?.get(it) ?: applicationLabel) } notificationManager.createNotificationChannelGroups(groups) @@ -550,24 +549,23 @@ class MeshNotificationManagerImpl( // InboxStyle, not MessagingStyle: the summary has no reply or mark-as-read actions, and Android Auto takes a // MessagingStyle notification for a conversation it can answer. val you = getStringSuspend(Res.string.you) - val lines = - activeNotifications.mapNotNull { sbn -> - // Prefer the child's real MessagingStyle: its latest message carries the actual sender, so the line - // reads "Hawk Ridge: …" rather than the conversation title. - val latest = - NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(sbn.notification) - ?.messages - ?.lastOrNull() - val sender = latest?.person?.name ?: latest?.let { you } - val text = latest?.text - if (sender != null && text != null) { - "$sender: $text" - } else { - val senderTitle = sbn.notification.extras.getCharSequence(Notification.EXTRA_TITLE) - val messageText = sbn.notification.extras.getCharSequence(Notification.EXTRA_TEXT) - if (senderTitle != null && messageText != null) "$senderTitle: $messageText" else null - } + val lines = activeNotifications.mapNotNull { sbn -> + // Prefer the child's real MessagingStyle: its latest message carries the actual sender, so the line + // reads "Hawk Ridge: …" rather than the conversation title. + val latest = + NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(sbn.notification) + ?.messages + ?.lastOrNull() + val sender = latest?.person?.name ?: latest?.let { you } + val text = latest?.text + if (sender != null && text != null) { + "$sender: $text" + } else { + val senderTitle = sbn.notification.extras.getCharSequence(Notification.EXTRA_TITLE) + val messageText = sbn.notification.extras.getCharSequence(Notification.EXTRA_TEXT) + if (senderTitle != null && messageText != null) "$senderTitle: $messageText" else null } + } val appName = getStringSuspend(Res.string.meshtastic_app_name) val inboxStyle = NotificationCompat.InboxStyle().setBigContentTitle(appName) lines.forEach { inboxStyle.addLine(it) } @@ -1184,7 +1182,8 @@ class MeshNotificationManagerImpl( } } parts.add(BULLET + getStringSuspend(Res.string.local_stats_nodes, num_online_nodes, num_total_nodes)) - parts.add(BULLET + getStringSuspend(Res.string.local_stats_uptime, formatUptime(uptime_seconds))) + val uptime = formatDurationSuspend(uptime_seconds.toLong()) + parts.add(BULLET + getStringSuspend(Res.string.local_stats_uptime, uptime)) parts.add( BULLET + getStringSuspend( @@ -1235,7 +1234,9 @@ class MeshNotificationManagerImpl( private suspend fun DeviceMetrics.formatToStringSuspend(): String { val parts = mutableListOf() battery_level?.let { parts.add(BULLET + getStringSuspend(Res.string.local_stats_battery, it)) } - uptime_seconds?.let { parts.add(BULLET + getStringSuspend(Res.string.local_stats_uptime, formatUptime(it))) } + uptime_seconds?.let { + parts.add(BULLET + getStringSuspend(Res.string.local_stats_uptime, formatDurationSuspend(it.toLong()))) + } if (channel_utilization != null || air_util_tx != null) { parts.add( BULLET + diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt index 1f12c25bd1..492033359b 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt @@ -18,41 +18,18 @@ package org.meshtastic.core.service import android.app.Application import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import co.touchlab.kermit.Logger import org.koin.core.annotation.Single import org.meshtastic.core.repository.SecurityKeyBackupStore import org.meshtastic.core.repository.StoredSecurityKeys /** - * Encrypted per-node storage for security key backups. - * - * Uses EncryptedSharedPreferences backed by an AES-256-GCM MasterKey (hardware keystore when available), mirroring + * Encrypted per-node storage for security key backups, in [openEncryptedPreferences] like * [LockdownPassphraseStoreImpl]. Keyed by node number, matching iOS's per-node Keychain entries. */ @Single(binds = [SecurityKeyBackupStore::class]) class SecurityKeyBackupStoreImpl(app: Application) : SecurityKeyBackupStore { - // androidx.security.crypto (MasterKey / EncryptedSharedPreferences) is deprecated by Google with no - // drop-in AndroidX replacement yet. Migrating encrypted storage is a separate, security-sensitive - // effort; suppress until a stable replacement (e.g. Tink) is adopted. - @Suppress("TooGenericExceptionCaught", "DEPRECATION") - private val prefs: SharedPreferences? by lazy { - try { - val masterKey = MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() - EncryptedSharedPreferences.create( - app, - PREFS_FILE_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } catch (e: Exception) { - Logger.e(e) { "Failed to initialize encrypted security key backup store" } - null - } - } + private val prefs: SharedPreferences? by lazy { openEncryptedPreferences(app, PREFS_FILE_NAME) } @Suppress("ReturnCount") override fun get(nodeNum: Int): StoredSecurityKeys? { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/StartMeshServiceOnAddressChange.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/StartMeshServiceOnAddressChange.kt new file mode 100644 index 0000000000..5bb3641ab7 --- /dev/null +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/StartMeshServiceOnAddressChange.kt @@ -0,0 +1,28 @@ +/* + * 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 android.content.Context +import org.koin.core.annotation.Single + +/** Starts [MeshService] for a newly selected device, since the foreground service owns the connection on Android. */ +@Single +class StartMeshServiceOnAddressChange(private val context: Context) : DeviceAddressChangeHook { + override fun onDeviceAddressChanged() { + MeshService.startService(context, ServiceStartTrigger.DeviceAddressChanged) + } +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt index 3c38fa6776..f5104739c2 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt @@ -16,85 +16,9 @@ */ package org.meshtastic.core.service.di -import android.content.Context import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module -import org.koin.core.annotation.Single -import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.common.di.ServiceScope -import org.meshtastic.core.repository.AdminController -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshMessageProcessor -import org.meshtastic.core.repository.MeshNotificationManager -import org.meshtastic.core.repository.MeshPrefs -import org.meshtastic.core.repository.MessagingController -import org.meshtastic.core.repository.NodeController -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.QueryController -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.RadioController -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.service.MeshService -import org.meshtastic.core.service.RadioControllerImpl -import org.meshtastic.core.service.ServiceStartTrigger -import org.meshtastic.core.service.startService @Module @ComponentScan("org.meshtastic.core.service") -class CoreServiceAndroidModule { - @Suppress("LongParameterList") - @Single( - binds = - [ - RadioController::class, - AdminController::class, - MessagingController::class, - NodeController::class, - QueryController::class, - ], - ) - fun radioController( - context: Context, - serviceRepository: ServiceRepository, - nodeRepository: NodeRepository, - commandSender: CommandSender, - nodeManager: NodeManager, - radioInterfaceService: RadioInterfaceService, - locationManager: MeshLocationManager, - packetRepository: Lazy, - dataHandler: Lazy, - analytics: PlatformAnalytics, - meshPrefs: MeshPrefs, - uiPrefs: UiPrefs, - databaseManager: DatabaseManager, - serviceNotifications: MeshNotificationManager, - messageProcessor: Lazy, - radioConfigRepository: RadioConfigRepository, - scope: ServiceScope, - ): RadioController = RadioControllerImpl( - serviceRepository = serviceRepository, - nodeRepository = nodeRepository, - commandSender = commandSender, - nodeManager = nodeManager, - radioInterfaceService = radioInterfaceService, - locationManager = locationManager, - packetRepository = packetRepository, - dataHandler = dataHandler, - analytics = analytics, - meshPrefs = meshPrefs, - uiPrefs = uiPrefs, - databaseManager = databaseManager, - serviceNotifications = serviceNotifications, - messageProcessor = messageProcessor, - radioConfigRepository = radioConfigRepository, - scope = scope, - onDeviceAddressChanged = { MeshService.startService(context, ServiceStartTrigger.DeviceAddressChanged) }, - ) -} +class CoreServiceAndroidModule diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DeviceAddressChangeHook.kt similarity index 72% rename from feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt rename to core/service/src/commonMain/kotlin/org/meshtastic/core/service/DeviceAddressChangeHook.kt index fd13a3b95d..5699adf916 100644 --- a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DeviceAddressChangeHook.kt @@ -14,12 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.settings.tak +package org.meshtastic.core.service -import androidx.compose.runtime.Composable - -@Composable -actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit = - { _ -> - // No-op on iOS for now - } +/** Platform work to run after [RadioControllerImpl] commits a new device address. */ +fun interface DeviceAddressChangeHook { + fun onDeviceAddressChanged() +} diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt index 82cc24204b..75a441fd7b 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt @@ -18,7 +18,6 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -30,7 +29,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.common.di.ServiceScope import org.meshtastic.core.model.ConnectionEpochs import org.meshtastic.core.model.ConnectionLifecycle import org.meshtastic.core.model.ConnectionState @@ -96,6 +97,16 @@ internal suspend fun restoreLocalConfigurationIfOwned( * surfacing, packet-id generation, location provisioning, and device-address switching. */ @Suppress("LongParameterList") +@Single( + binds = + [ + RadioController::class, + AdminController::class, + MessagingController::class, + NodeController::class, + QueryController::class, + ], +) class RadioControllerImpl( private val serviceRepository: ServiceRepository, nodeRepository: NodeRepository, @@ -112,8 +123,8 @@ class RadioControllerImpl( private val serviceNotifications: MeshNotificationManager, private val messageProcessor: Lazy, radioConfigRepository: RadioConfigRepository, - scope: CoroutineScope, - private val onDeviceAddressChanged: (() -> Unit)? = null, + scope: ServiceScope, + private val deviceAddressChangeHook: DeviceAddressChangeHook, ) : RadioController, AdminController by AdminControllerImpl( commandSender = commandSender, @@ -303,7 +314,7 @@ class RadioControllerImpl( } } // Keep callbacks outside the transition mutex so observers cannot deadlock by scheduling another selection. - onDeviceAddressChanged?.invoke() + deviceAddressChangeHook.onDeviceAddressChanged() } override fun requestGattCacheInvalidationOnNextConnect() { diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt index 2d8cd06746..e30b52438a 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.common.di.asServiceScope import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus @@ -176,8 +177,8 @@ class RadioControllerImplTest { serviceNotifications = serviceNotifications, messageProcessor = lazy { messageProcessor }, radioConfigRepository = radioConfigRepository, - scope = scope, - onDeviceAddressChanged = onDeviceAddressChanged, + scope = scope.asServiceScope(), + deviceAddressChangeHook = { onDeviceAddressChanged?.invoke() }, ) } diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt index f2cd21eca6..0eb073c83e 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerRestoreTest.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.common.di.asServiceScope import org.meshtastic.core.repository.AdminEditScope import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshDataHandler @@ -102,7 +103,8 @@ class RadioControllerRestoreTest { serviceNotifications = serviceNotifications, messageProcessor = lazy { messageProcessor }, radioConfigRepository = radioConfigRepository, - scope = scope, + scope = scope.asServiceScope(), + deviceAddressChangeHook = {}, ) } } diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/DesktopKeystoreCipher.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/DesktopKeystoreCipher.kt new file mode 100644 index 0000000000..b6797379f5 --- /dev/null +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/DesktopKeystoreCipher.kt @@ -0,0 +1,92 @@ +/* + * 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 java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * AES-256-GCM encryption for the desktop secure stores, keyed by a master key held in a PKCS12 keystore in [dir]. + * + * The keystore password is fixed because the threat model mirrors Android's `EncryptedSharedPreferences`: file-system + * permission is the primary access control; the encryption layer protects data at rest against casual file browsing or + * backup leakage, not against a compromised user account. + */ +internal class DesktopKeystoreCipher( + private val dir: File, + private val keyAlias: String, + private val keystorePassword: CharArray, +) { + /** + * Loads the master key, creating the keystore and key on first use. + * + * Throws when the keystore exists but holds no key under [keyAlias]: generating a replacement would overwrite the + * keystore and orphan every file encrypted under the old key. + */ + fun loadOrCreateMasterKey(): SecretKey { + val ksFile = File(dir, KEYSTORE_FILE) + val ks = KeyStore.getInstance(KEYSTORE_TYPE) + val protection = KeyStore.PasswordProtection(keystorePassword) + if (ksFile.exists()) { + FileInputStream(ksFile).use { ks.load(it, keystorePassword) } + val entry = ks.getEntry(keyAlias, protection) + check(entry is KeyStore.SecretKeyEntry) { "Keystore exists but master key $keyAlias is missing/invalid" } + return entry.secretKey + } + val keyGen = KeyGenerator.getInstance(AES_ALGORITHM) + keyGen.init(AES_KEY_BITS) + val secretKey = keyGen.generateKey() + ks.load(null, keystorePassword) + ks.setEntry(keyAlias, KeyStore.SecretKeyEntry(secretKey), protection) + FileOutputStream(ksFile).use { ks.store(it, keystorePassword) } + return secretKey + } + + /** Encrypts [plaintext] as `[1 byte IV length][IV][ciphertext]`. */ + fun encrypt(key: SecretKey, plaintext: ByteArray): ByteArray { + val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) + cipher.init(Cipher.ENCRYPT_MODE, key) + val iv = cipher.iv + val ciphertext = cipher.doFinal(plaintext) + return byteArrayOf(iv.size.toByte()) + iv + ciphertext + } + + fun decrypt(key: SecretKey, data: ByteArray): ByteArray { + val ivLength = data[0].toInt() and BYTE_MASK + val iv = data.copyOfRange(1, 1 + ivLength) + val ciphertext = data.copyOfRange(1 + ivLength, data.size) + val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv)) + return cipher.doFinal(ciphertext) + } + + private companion object { + private const val KEYSTORE_FILE = "keystore.p12" + private const val KEYSTORE_TYPE = "PKCS12" + private const val AES_ALGORITHM = "AES" + private const val AES_GCM_TRANSFORM = "AES/GCM/NoPadding" + private const val AES_KEY_BITS = 256 + private const val GCM_TAG_BITS = 128 + private const val BYTE_MASK = 0xFF + } +} diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt index 5b3d6df0d6..4d277975a5 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/JvmFileService.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withContext import okio.BufferedSink import okio.BufferedSource @@ -28,17 +29,19 @@ import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FileService import java.io.File +import java.net.URI @Single class JvmFileService(private val dispatchers: CoroutineDispatchers) : FileService { override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean = withContext(dispatchers.io) { try { - // Treat URI string as a local file path - val file = File(uri.toString()) + val file = uri.toFile() file.parentFile?.mkdirs() file.sink().buffer().use { sink -> block(sink) } true + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Logger.e(e) { "Failed to write to URI: $uri" } false @@ -48,12 +51,21 @@ class JvmFileService(private val dispatchers: CoroutineDispatchers) : FileServic override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean = withContext(dispatchers.io) { try { - val file = File(uri.toString()) + val file = uri.toFile() file.source().buffer().use { source -> block(source) } true + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Logger.e(e) { "Failed to read from URI: $uri" } false } } + + /** The desktop file pickers hand back `file:` URIs; anything else is taken as a plain path. */ + private fun CommonUri.toFile(): File { + val text = toString() + val parsed = runCatching { URI(text) }.getOrNull() + return if (parsed?.scheme == "file") File(parsed) else File(text) + } } diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt index 868f72a75c..d93d076f6f 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImpl.kt @@ -22,34 +22,25 @@ import org.meshtastic.core.database.desktopDataDir import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.repository.StoredPassphrase import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec /** * File-backed encrypted passphrase store for JVM/Desktop. * - * Uses a PKCS12 KeyStore to hold an AES-256 master key and AES-256-GCM to encrypt each passphrase entry. Entries are - * stored as individual `.enc` files under `$MESHTASTIC_DATA_DIR/lockdown/` (default: `~/.meshtastic/lockdown/`), keyed - * by a sanitized device address. - * - * The keystore password is fixed because the threat model mirrors Android's `EncryptedSharedPreferences`: file-system - * permission is the primary access control; the encryption layer protects data at rest against casual file browsing or - * backup leakage, not against a compromised user account. + * Each passphrase entry is an AES-256-GCM `.enc` file under `$MESHTASTIC_DATA_DIR/lockdown/` (default: + * `~/.meshtastic/lockdown/`), keyed by a sanitized device address; see [DesktopKeystoreCipher] for the key handling. */ @Single(binds = [LockdownPassphraseStore::class]) @Suppress("TooGenericExceptionCaught") -class LockdownPassphraseStoreImpl : LockdownPassphraseStore { +class LockdownPassphraseStoreImpl(dataDir: File = File(desktopDataDir())) : LockdownPassphraseStore { - private val lockdownDir: File by lazy { File(desktopDataDir(), LOCKDOWN_DIR).also { it.mkdirs() } } + private val lockdownDir: File by lazy { File(dataDir, LOCKDOWN_DIR).also { it.mkdirs() } } + + private val cipher by lazy { DesktopKeystoreCipher(lockdownDir, KEY_ALIAS, KEYSTORE_PASSWORD) } private val masterKey: SecretKey? by lazy { try { - loadOrCreateMasterKey() + cipher.loadOrCreateMasterKey() } catch (e: Exception) { Logger.e(e) { "Lockdown: Failed to initialize desktop keystore" } null @@ -62,8 +53,7 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { val file = entryFile(deviceAddress) if (!file.exists()) return null return try { - val encrypted = file.readBytes() - val plaintext = decrypt(key, encrypted) + val plaintext = cipher.decrypt(key, file.readBytes()) deserialize(plaintext) } catch (e: Exception) { Logger.e(e) { "Lockdown: Failed to read passphrase for device" } @@ -80,8 +70,7 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { ) { val key = masterKey ?: error("Lockdown: Cannot save passphrase - keystore unavailable") val plaintext = serialize(passphrase, boots, hours, maxSessionSeconds) - val encrypted = encrypt(key, plaintext) - entryFile(deviceAddress).writeBytes(encrypted) + entryFile(deviceAddress).writeBytes(cipher.encrypt(key, plaintext)) } override fun clearPassphrase(deviceAddress: String) { @@ -96,28 +85,6 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { return File(lockdownDir, "$sanitized.enc") } - // region Encryption - - private fun encrypt(key: SecretKey, plaintext: ByteArray): ByteArray { - val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) - cipher.init(Cipher.ENCRYPT_MODE, key) - val iv = cipher.iv - val ciphertext = cipher.doFinal(plaintext) - // Format: [1 byte IV length][IV][ciphertext] - return byteArrayOf(iv.size.toByte()) + iv + ciphertext - } - - private fun decrypt(key: SecretKey, data: ByteArray): ByteArray { - val ivLength = data[0].toInt() and BYTE_MASK - val iv = data.copyOfRange(1, 1 + ivLength) - val ciphertext = data.copyOfRange(1 + ivLength, data.size) - val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) - cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv)) - return cipher.doFinal(ciphertext) - } - - // endregion - // region Serialization (simple line-based to avoid adding kotlinx-serialization dependency) // Format v2: "boots\nhours\nmaxSessionSeconds\npassphrase" (4 lines). @@ -160,42 +127,12 @@ class LockdownPassphraseStoreImpl : LockdownPassphraseStore { // endregion - // region KeyStore - - private fun loadOrCreateMasterKey(): SecretKey { - val ksFile = File(lockdownDir, KEYSTORE_FILE) - val ks = KeyStore.getInstance(KEYSTORE_TYPE) - val protection = KeyStore.PasswordProtection(KEYSTORE_PASSWORD) - if (ksFile.exists()) { - FileInputStream(ksFile).use { ks.load(it, KEYSTORE_PASSWORD) } - val entry = ks.getEntry(KEY_ALIAS, protection) - if (entry is KeyStore.SecretKeyEntry) return entry.secretKey - } - // Generate new master key - val keyGen = KeyGenerator.getInstance(AES_ALGORITHM) - keyGen.init(AES_KEY_BITS) - val secretKey = keyGen.generateKey() - ks.load(null, KEYSTORE_PASSWORD) - ks.setEntry(KEY_ALIAS, KeyStore.SecretKeyEntry(secretKey), protection) - FileOutputStream(ksFile).use { ks.store(it, KEYSTORE_PASSWORD) } - return secretKey - } - - // endregion - private companion object { private const val LOCKDOWN_DIR = "lockdown" - private const val KEYSTORE_FILE = "keystore.p12" - private const val KEYSTORE_TYPE = "PKCS12" private const val KEY_ALIAS = "lockdown_master" // Intentional: this mirrors the documented desktop threat model for at-rest protection only. private val KEYSTORE_PASSWORD = "meshtastic-lockdown".toCharArray() - private const val AES_ALGORITHM = "AES" - private const val AES_GCM_TRANSFORM = "AES/GCM/NoPadding" - private const val AES_KEY_BITS = 256 - private const val GCM_TAG_BITS = 128 - private const val BYTE_MASK = 0xFF private const val SERIALIZED_LINE_COUNT_V1 = 3 private const val SERIALIZED_LINE_COUNT_V2 = 4 } diff --git a/feature/discovery/src/iosMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.ios.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/NoopDeviceAddressChangeHook.kt similarity index 69% rename from feature/discovery/src/iosMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.ios.kt rename to core/service/src/jvmMain/kotlin/org/meshtastic/core/service/NoopDeviceAddressChangeHook.kt index 5f9777a7af..b8b6d81e78 100644 --- a/feature/discovery/src/iosMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.ios.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/NoopDeviceAddressChangeHook.kt @@ -14,12 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.discovery.export +package org.meshtastic.core.service -import androidx.compose.runtime.Composable -import co.touchlab.kermit.Logger +import org.koin.core.annotation.Single -@Composable -actual fun rememberExportSaver(): ExportSaverLauncher = ExportSaverLauncher { result -> - Logger.w { "Export save not yet implemented on iOS: ${result.fileName}" } +/** Desktop runs the service in-process, so a new address needs nothing started. */ +@Single +class NoopDeviceAddressChangeHook : DeviceAddressChangeHook { + override fun onDeviceAddressChanged() = Unit } diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt index 891338d5d5..2838ce9f66 100644 --- a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt +++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt @@ -22,28 +22,24 @@ import org.meshtastic.core.database.desktopDataDir import org.meshtastic.core.repository.SecurityKeyBackupStore import org.meshtastic.core.repository.StoredSecurityKeys import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec /** - * File-backed encrypted key-backup store for JVM/Desktop, mirroring [LockdownPassphraseStoreImpl]'s desktop - * counterpart. Uses a PKCS12 KeyStore to hold an AES-256 master key and AES-256-GCM to encrypt each node's key backup, - * stored as individual `.enc` files under `$MESHTASTIC_DATA_DIR/security_keys/`. + * File-backed encrypted key-backup store for JVM/Desktop, mirroring [LockdownPassphraseStoreImpl]. Each node's key + * backup is an AES-256-GCM `.enc` file under `$MESHTASTIC_DATA_DIR/security_keys/`; see [DesktopKeystoreCipher] for the + * key handling. */ @Single(binds = [SecurityKeyBackupStore::class]) @Suppress("TooGenericExceptionCaught") -class SecurityKeyBackupStoreImpl : SecurityKeyBackupStore { +class SecurityKeyBackupStoreImpl(dataDir: File = File(desktopDataDir())) : SecurityKeyBackupStore { - private val storeDir: File by lazy { File(desktopDataDir(), STORE_DIR).also { it.mkdirs() } } + private val storeDir: File by lazy { File(dataDir, STORE_DIR).also { it.mkdirs() } } + + private val cipher by lazy { DesktopKeystoreCipher(storeDir, KEY_ALIAS, KEYSTORE_PASSWORD) } private val masterKey: SecretKey? by lazy { try { - loadOrCreateMasterKey() + cipher.loadOrCreateMasterKey() } catch (e: Exception) { Logger.e(e) { "SecurityKeyBackup: Failed to initialize desktop keystore" } null @@ -56,7 +52,7 @@ class SecurityKeyBackupStoreImpl : SecurityKeyBackupStore { val file = entryFile(nodeNum) if (!file.exists()) return null return try { - val plaintext = decrypt(key, file.readBytes()) + val plaintext = cipher.decrypt(key, file.readBytes()) deserialize(plaintext) } catch (e: Exception) { Logger.e(e) { "SecurityKeyBackup: Failed to read key backup for node" } @@ -67,7 +63,7 @@ class SecurityKeyBackupStoreImpl : SecurityKeyBackupStore { override fun save(nodeNum: Int, publicKeyBase64: String, privateKeyBase64: String, timestamp: Long) { val key = masterKey ?: error("SecurityKeyBackup: Cannot save keys - keystore unavailable") val plaintext = "$timestamp\n$publicKeyBase64\n$privateKeyBase64".encodeToByteArray() - entryFile(nodeNum).writeBytes(encrypt(key, plaintext)) + entryFile(nodeNum).writeBytes(cipher.encrypt(key, plaintext)) } override fun delete(nodeNum: Int) { @@ -90,65 +86,12 @@ class SecurityKeyBackupStoreImpl : SecurityKeyBackupStore { return StoredSecurityKeys(publicKeyBase64 = parts[1], privateKeyBase64 = parts[2], timestamp = timestamp) } - // region Encryption - - private fun encrypt(key: SecretKey, plaintext: ByteArray): ByteArray { - val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) - cipher.init(Cipher.ENCRYPT_MODE, key) - val iv = cipher.iv - val ciphertext = cipher.doFinal(plaintext) - // Format: [1 byte IV length][IV][ciphertext] - return byteArrayOf(iv.size.toByte()) + iv + ciphertext - } - - private fun decrypt(key: SecretKey, data: ByteArray): ByteArray { - val ivLength = data[0].toInt() and BYTE_MASK - val iv = data.copyOfRange(1, 1 + ivLength) - val ciphertext = data.copyOfRange(1 + ivLength, data.size) - val cipher = Cipher.getInstance(AES_GCM_TRANSFORM) - cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv)) - return cipher.doFinal(ciphertext) - } - - // endregion - - // region KeyStore - - private fun loadOrCreateMasterKey(): SecretKey { - val ksFile = File(storeDir, KEYSTORE_FILE) - val ks = KeyStore.getInstance(KEYSTORE_TYPE) - val protection = KeyStore.PasswordProtection(KEYSTORE_PASSWORD) - if (ksFile.exists()) { - FileInputStream(ksFile).use { ks.load(it, KEYSTORE_PASSWORD) } - val entry = ks.getEntry(KEY_ALIAS, protection) - // Fail loudly rather than regenerate: overwriting the master key would orphan every existing .enc backup. - check(entry is KeyStore.SecretKeyEntry) { "Keystore exists but master key $KEY_ALIAS is missing/invalid" } - return entry.secretKey - } - val keyGen = KeyGenerator.getInstance(AES_ALGORITHM) - keyGen.init(AES_KEY_BITS) - val secretKey = keyGen.generateKey() - ks.load(null, KEYSTORE_PASSWORD) - ks.setEntry(KEY_ALIAS, KeyStore.SecretKeyEntry(secretKey), protection) - FileOutputStream(ksFile).use { ks.store(it, KEYSTORE_PASSWORD) } - return secretKey - } - - // endregion - private companion object { private const val STORE_DIR = "security_keys" - private const val KEYSTORE_FILE = "keystore.p12" - private const val KEYSTORE_TYPE = "PKCS12" private const val KEY_ALIAS = "security_key_backup_master" // Intentional: mirrors LockdownPassphraseStoreImpl's documented desktop threat model. private val KEYSTORE_PASSWORD = "meshtastic-security-keys".toCharArray() - private const val AES_ALGORITHM = "AES" - private const val AES_GCM_TRANSFORM = "AES/GCM/NoPadding" - private const val AES_KEY_BITS = 256 - private const val GCM_TAG_BITS = 128 - private const val BYTE_MASK = 0xFF private const val SERIALIZED_LINE_COUNT = 3 } } diff --git a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/JvmFileServiceTest.kt b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/JvmFileServiceTest.kt new file mode 100644 index 0000000000..90ede811be --- /dev/null +++ b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/JvmFileServiceTest.kt @@ -0,0 +1,68 @@ +/* + * 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.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.di.CoroutineDispatchers +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class JvmFileServiceTest { + private lateinit var dir: File + + @BeforeTest + fun setUp() { + dir = Files.createTempDirectory("jvm-file-service-test").toFile() + } + + @AfterTest + fun tearDown() { + dir.deleteRecursively() + } + + @Test + fun `a file URI from the save dialog is written at its own path`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val service = JvmFileService(CoroutineDispatchers(dispatcher, dispatcher, dispatcher)) + val target = File(dir, "export.txt") + + val written = service.write(CommonUri.parse(target.toURI().toString())) { it.writeUtf8("hello") } + + assertTrue(written) + assertEquals("hello", target.readText()) + } + + @Test + fun `a file URI is read from its own path`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val service = JvmFileService(CoroutineDispatchers(dispatcher, dispatcher, dispatcher)) + val source = File(dir, "profile.cfg").apply { writeText("config") } + var text: String? = null + + val read = service.read(CommonUri.parse(source.toURI().toString())) { text = it.readUtf8() } + + assertTrue(read) + assertEquals("config", text) + } +} diff --git a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/KeystoreFixtures.kt b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/KeystoreFixtures.kt new file mode 100644 index 0000000000..e451925f95 --- /dev/null +++ b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/KeystoreFixtures.kt @@ -0,0 +1,34 @@ +/* + * 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 java.io.File +import java.security.KeyStore +import kotlin.test.assertTrue + +/** + * Deletes [alias] from the store's `keystore.p12`, leaving the keystore file in place. Loading with the store's own + * [password] and asserting the alias existed keeps a stale literal from turning the caller into a no-op. + */ +internal fun removeKeystoreEntry(storeDir: File, alias: String, password: String) { + val file = File(storeDir, "keystore.p12") + val keyStore = KeyStore.getInstance("PKCS12") + file.inputStream().use { keyStore.load(it, password.toCharArray()) } + assertTrue(keyStore.containsAlias(alias), "fixture expects $alias in ${file.name}") + keyStore.deleteEntry(alias) + file.outputStream().use { keyStore.store(it, password.toCharArray()) } +} diff --git a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt index fa02d1350f..60fba18b92 100644 --- a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt +++ b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/LockdownPassphraseStoreImplTest.kt @@ -21,29 +21,27 @@ import java.nio.file.Files import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull class LockdownPassphraseStoreImplTest { - private lateinit var tempHome: java.nio.file.Path - private lateinit var originalUserHome: String + private lateinit var dataDir: File @BeforeTest fun setUp() { - originalUserHome = System.getProperty("user.home") - tempHome = Files.createTempDirectory("lockdown-passphrase-store-test") - System.setProperty("user.home", tempHome.toString()) + dataDir = Files.createTempDirectory("lockdown-passphrase-store-test").toFile() } @AfterTest fun tearDown() { - System.setProperty("user.home", originalUserHome) - File(tempHome.toString()).deleteRecursively() + dataDir.deleteRecursively() } @Test fun `save get and clear passphrase round trips on jvm`() { - val store = LockdownPassphraseStoreImpl() + val store = LockdownPassphraseStoreImpl(dataDir) store.savePassphrase(deviceAddress = "AA:BB:CC:DD", passphrase = "secret", boots = 10, hours = 24) @@ -56,4 +54,20 @@ class LockdownPassphraseStoreImplTest { assertNull(store.getPassphrase("AA:BB:CC:DD")) } + + @Test + fun `a keystore missing its master key is never overwritten`() { + LockdownPassphraseStoreImpl(dataDir).savePassphrase("AA:BB:CC:DD", "secret", boots = 10, hours = 24) + val storeDir = File(dataDir, "lockdown") + removeKeystoreEntry(storeDir, alias = "lockdown_master", password = "meshtastic-lockdown") + val keystoreBefore = File(storeDir, "keystore.p12").readBytes() + val entryBefore = File(storeDir, "AA_BB_CC_DD.enc").readBytes() + + val reopened = LockdownPassphraseStoreImpl(dataDir) + + assertNull(reopened.getPassphrase("AA:BB:CC:DD")) + assertFailsWith { reopened.savePassphrase("EE:FF", "other", boots = 1, hours = 1) } + assertContentEquals(keystoreBefore, File(storeDir, "keystore.p12").readBytes()) + assertContentEquals(entryBefore, File(storeDir, "AA_BB_CC_DD.enc").readBytes()) + } } diff --git a/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImplTest.kt b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImplTest.kt new file mode 100644 index 0000000000..5c66f75b35 --- /dev/null +++ b/core/service/src/jvmTest/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImplTest.kt @@ -0,0 +1,73 @@ +/* + * 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 java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class SecurityKeyBackupStoreImplTest { + private lateinit var dataDir: File + + @BeforeTest + fun setUp() { + dataDir = Files.createTempDirectory("security-key-backup-store-test").toFile() + } + + @AfterTest + fun tearDown() { + dataDir.deleteRecursively() + } + + @Test + fun `save get and delete round trips on jvm`() { + val store = SecurityKeyBackupStoreImpl(dataDir) + + store.save(nodeNum = 42, publicKeyBase64 = "pub", privateKeyBase64 = "priv", timestamp = 1_700_000_000L) + + val stored = store.get(42) + assertEquals("pub", stored?.publicKeyBase64) + assertEquals("priv", stored?.privateKeyBase64) + assertEquals(1_700_000_000L, stored?.timestamp) + + store.delete(42) + + assertNull(store.get(42)) + } + + @Test + fun `a keystore missing its master key is never overwritten`() { + SecurityKeyBackupStoreImpl(dataDir).save(42, "pub", "priv", timestamp = 1L) + val storeDir = File(dataDir, "security_keys") + removeKeystoreEntry(storeDir, alias = "security_key_backup_master", password = "meshtastic-security-keys") + val keystoreBefore = File(storeDir, "keystore.p12").readBytes() + val entryBefore = File(storeDir, "42.enc").readBytes() + + val reopened = SecurityKeyBackupStoreImpl(dataDir) + + assertNull(reopened.get(42)) + assertFailsWith { reopened.save(7, "pub7", "priv7", timestamp = 2L) } + assertContentEquals(keystoreBefore, File(storeDir, "keystore.p12").readBytes()) + assertContentEquals(entryBefore, File(storeDir, "42.enc").readBytes()) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt index eacf391962..d44c3cdadf 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.a11y_node_battery import org.meshtastic.core.resources.a11y_node_distance_away @@ -36,7 +37,6 @@ import org.meshtastic.core.resources.unknown import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.proto.Config.LoRaConfig.ModemPreset -private const val MILLIS_PER_SECOND = 1000L private const val MAX_BATTERY_PERCENT = 100 /** Pre-resolved localized strings for TalkBack node descriptions. */ @@ -107,7 +107,7 @@ internal fun buildNodeDescription( if (lastHeardIsRelative) { formatAgo(lastHeard, strings.unknown, strings.now) } else { - DateFormatter.formatDateTime(lastHeard.toLong() * MILLIS_PER_SECOND) + DateFormatter.formatDateTime(lastHeard.toLong() * TimeConstants.MS_PER_SEC) } append(", ") append(strings.lastHeard.replace("%s", timeText)) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt index 6666c6fca2..89dbed2def 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt @@ -25,14 +25,13 @@ import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.ic_antenna import org.meshtastic.core.resources.node_sort_last_heard import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.core.ui.util.formatAgo -private const val MILLIS_PER_SECOND = 1000L - @Composable fun LastHeardInfo( modifier: Modifier = Modifier, @@ -45,7 +44,7 @@ fun LastHeardInfo( if (relative) { formatAgo(lastHeard) } else { - DateFormatter.formatDateTime(lastHeard.toLong() * MILLIS_PER_SECOND) + DateFormatter.formatDateTime(lastHeard.toLong() * TimeConstants.MS_PER_SEC) } IconInfo( modifier = modifier, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/FileExporter.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/FileExporter.kt new file mode 100644 index 0000000000..b47fc05548 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/FileExporter.kt @@ -0,0 +1,61 @@ +/* + * 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.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import co.touchlab.kermit.Logger +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.compose.koinInject +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.common.util.safeCatching +import org.meshtastic.core.repository.FileService + +/** + * Returns a launcher that asks the user where to save a file, then writes the bytes [content] produces there through + * [FileService] and reports whether the export landed to [onResult]. [content] runs only after a destination is chosen; + * returning null means there is nothing to export. + */ +@Composable +fun rememberFileExporter( + content: suspend () -> ByteArray?, + onResult: suspend (exported: Boolean) -> Unit = {}, +): (fileName: String, mimeType: String) -> Unit { + val fileService: FileService = koinInject() + val scope = rememberCoroutineScope() + val currentContent by rememberUpdatedState(content) + val currentOnResult by rememberUpdatedState(onResult) + return rememberSaveFileLauncher { uri -> + scope.launch { currentOnResult(writeExport(fileService, uri, currentContent)) } + } +} + +/** + * Writes the bytes [content] produces to [uri]. Returns false without writing when [content] returns null or throws, + * and false when [FileService] reports the write failed. + */ +internal suspend fun writeExport(fileService: FileService, uri: CommonUri, content: suspend () -> ByteArray?): Boolean { + val bytes = + safeCatching { withContext(ioDispatcher) { content() } } + .onFailure { e -> Logger.e(e) { "Could not produce the export" } } + .getOrNull() ?: return false + return fileService.write(uri) { it.write(bytes) } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt index 11125aa92c..662f80dfc7 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext 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.util.TimeConstants import org.meshtastic.core.model.util.channelIdentity import org.meshtastic.core.model.util.isChannelPlaceholder import org.meshtastic.core.model.util.toChannelReplacementPlan @@ -39,18 +40,16 @@ import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.Position import kotlin.time.Duration.Companion.days -private const val SECONDS_TO_MILLIS = 1000L - @Composable fun Position.formatPositionTime(): String { val currentTime = nowMillis val sixMonthsAgo = currentTime - 180.days.inWholeMilliseconds - val isOlderThanSixMonths = time * SECONDS_TO_MILLIS < sixMonthsAgo + val isOlderThanSixMonths = time * TimeConstants.MS_PER_SEC < sixMonthsAgo val timeText = if (isOlderThanSixMonths) { stringResource(Res.string.unknown_age) } else { - DateFormatter.formatDateTime(time * SECONDS_TO_MILLIS) + DateFormatter.formatDateTime(time * TimeConstants.MS_PER_SEC) } return timeText } diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/WriteExportTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/WriteExportTest.kt new file mode 100644 index 0000000000..8590f0cb09 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/WriteExportTest.kt @@ -0,0 +1,88 @@ +/* + * 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.ui.util + +import kotlinx.coroutines.test.runTest +import okio.Buffer +import okio.BufferedSink +import okio.BufferedSource +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.repository.FileService +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WriteExportTest { + + private class RecordingFileService(private val writeSucceeds: Boolean = true) : FileService { + val written = Buffer() + var writes = 0 + private set + + override suspend fun write(uri: CommonUri, block: suspend (BufferedSink) -> Unit): Boolean { + writes++ + if (!writeSucceeds) return false + block(written) + return true + } + + override suspend fun read(uri: CommonUri, block: suspend (BufferedSource) -> Unit): Boolean = false + } + + private val uri = CommonUri.parse("file:///tmp/export.txt") + + @Test + fun `produced bytes are written and reported as exported`() = runTest { + val fileService = RecordingFileService() + + val exported = writeExport(fileService, uri) { "log line".encodeToByteArray() } + + assertTrue(exported) + assertEquals("log line", fileService.written.readUtf8()) + } + + @Test + fun `nothing to export is reported as a failure without opening the file`() = runTest { + val fileService = RecordingFileService() + + val exported = writeExport(fileService, uri) { null } + + assertFalse(exported) + assertEquals(0, fileService.writes) + } + + @Test + fun `content that throws is reported as a failure without opening the file`() = runTest { + val fileService = RecordingFileService() + + val exported = writeExport(fileService, uri) { error("log query failed") } + + assertFalse(exported) + assertEquals(0, fileService.writes) + } + + @Test + fun `a failed write is reported as a failure`() = runTest { + val fileService = RecordingFileService(writeSucceeds = false) + + val exported = writeExport(fileService, uri) { "log line".encodeToByteArray() } + + assertFalse(exported) + assertEquals(1, fileService.writes) + } +} diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt index 8504b4074a..953656b85e 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt @@ -33,8 +33,6 @@ import org.koin.core.annotation.Single import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.ble.BluetoothRepository -import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.common.di.ServiceScope import org.meshtastic.core.data.datasource.BundledAssetReader import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.network.HttpClientDefaults @@ -42,34 +40,18 @@ import org.meshtastic.core.network.KermitHttpLogger import org.meshtastic.core.network.configureDefaultRetry import org.meshtastic.core.network.service.ApiService import org.meshtastic.core.network.service.ApiServiceImpl -import org.meshtastic.core.repository.AdminController -import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.ConnectionStateProvider -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshNotificationManager -import org.meshtastic.core.repository.MeshPrefs import org.meshtastic.core.repository.MessageQueue -import org.meshtastic.core.repository.MessagingController import org.meshtastic.core.repository.NeighborInfoResponseProvider -import org.meshtastic.core.repository.NodeController -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.QueryController -import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController -import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioTransportFactory import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.core.repository.TracerouteResponseProvider -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.service.RadioControllerImpl import org.meshtastic.core.service.ServiceRepositoryImpl import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.desktop.DesktopNotificationManager @@ -115,53 +97,6 @@ class DesktopRuntimeModule { connectionFactory = connectionFactory, ) - @Suppress("LongParameterList") - @Single( - binds = - [ - RadioController::class, - AdminController::class, - MessagingController::class, - NodeController::class, - QueryController::class, - ], - ) - fun radioController( - serviceRepository: ServiceRepository, - nodeRepository: NodeRepository, - commandSender: CommandSender, - nodeManager: NodeManager, - radioInterfaceService: RadioInterfaceService, - locationManager: MeshLocationManager, - packetRepository: Lazy, - dataHandler: Lazy, - analytics: PlatformAnalytics, - meshPrefs: MeshPrefs, - uiPrefs: UiPrefs, - databaseManager: DatabaseManager, - serviceNotifications: MeshNotificationManager, - messageProcessor: Lazy, - radioConfigRepository: RadioConfigRepository, - scope: ServiceScope, - ): RadioController = RadioControllerImpl( - serviceRepository = serviceRepository, - nodeRepository = nodeRepository, - commandSender = commandSender, - nodeManager = nodeManager, - radioInterfaceService = radioInterfaceService, - locationManager = locationManager, - packetRepository = packetRepository, - dataHandler = dataHandler, - analytics = analytics, - meshPrefs = meshPrefs, - uiPrefs = uiPrefs, - databaseManager = databaseManager, - serviceNotifications = serviceNotifications, - messageProcessor = messageProcessor, - radioConfigRepository = radioConfigRepository, - scope = scope, - ) - /** * Only the Linux sender holds a native handle; the others are stateless. `Main.kt` closes it explicitly during * shutdown, because annotations have no `onClose` equivalent. diff --git a/feature/discovery/README.md b/feature/discovery/README.md index df0e6748c3..187fdcc2e6 100644 --- a/feature/discovery/README.md +++ b/feature/discovery/README.md @@ -40,7 +40,7 @@ Multiplatform export path in `export/`: - `DiscoveryExporter` — interface: `export(DiscoveryExportData): ExportResult` (success = bytes + MIME type + filename). - `DiscoveryReportFormatter` — shared formatting of session/preset report lines and filenames. -- `rememberExportSaver()` / `ExportSaverLauncher` — `expect`/`actual` file-save seam per platform (`ExportSaver.android.kt` → SAF document picker, `ExportSaver.jvm.kt` → file dialog, `ExportSaver.ios.kt`). +- `rememberExportSaver()` / `ExportSaverLauncher` save an `ExportResult.Success` to a user-picked file through `core:ui`'s `rememberFileExporter`. - `PdfDiscoveryExporter` (androidMain) renders a PDF report; `TextDiscoveryExporter` (jvmMain) renders plain text. ## Mesh Beacon Invitations diff --git a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.android.kt b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.android.kt deleted file mode 100644 index e8c8e53a4d..0000000000 --- a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.android.kt +++ /dev/null @@ -1,65 +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 org.meshtastic.feature.discovery.export - -import android.content.Intent -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalContext -import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Composable -actual fun rememberExportSaver(): ExportSaverLauncher { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val pendingExport = remember { mutableStateOf(null) } - - val launcher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val uri = result.data?.data ?: return@rememberLauncherForActivityResult - val export = pendingExport.value ?: return@rememberLauncherForActivityResult - pendingExport.value = null - scope.launch { - withContext(Dispatchers.IO) { - @Suppress("TooGenericExceptionCaught") - try { - context.contentResolver.openOutputStream(uri)?.use { it.write(export.content) } - } catch (e: Exception) { - Logger.e(throwable = e) { "Failed to write export file" } - } - } - } - } - - return ExportSaverLauncher { result -> - pendingExport.value = result - val intent = - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = result.mimeType - putExtra(Intent.EXTRA_TITLE, result.fileName) - } - launcher.launch(intent) - } -} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt index aa24910ab4..d3c48c5b72 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt @@ -17,24 +17,31 @@ package org.meshtastic.feature.discovery.export import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.DurationUnitLabels import org.meshtastic.core.common.util.MeasurementSystem import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.NumberFormatter +import org.meshtastic.core.common.util.formatDuration import org.meshtastic.core.database.entity.DiscoveredNodeEntity import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity import org.meshtastic.core.database.entity.DiscoverySessionEntity +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.util.toDistanceString -import org.meshtastic.feature.discovery.ui.formatDuration import kotlin.math.roundToInt +import kotlin.time.DurationUnit internal object DiscoveryReportFormatter { + // The report's labels are English, so its durations are too. + private fun reportDuration(totalSeconds: Long): String = + formatDuration(totalSeconds, DurationUnitLabels.English, smallest = DurationUnit.MINUTES) + fun formatSessionDate(session: DiscoverySessionEntity): String = DateFormatter.formatDateTime(session.timestamp) fun formatSessionOverviewLines(session: DiscoverySessionEntity): List> = listOf( "Date" to formatSessionDate(session), "Total unique nodes" to session.totalUniqueNodes.toString(), - "Total dwell time" to formatDuration(session.totalDwellSeconds), + "Total dwell time" to reportDuration(session.totalDwellSeconds), "Status" to session.completionStatus.replaceFirstChar { it.uppercase() }, "Channel utilization" to "${NumberFormatter.format(session.avgChannelUtilization, 1)}%", "Total messages" to session.totalMessages.toString(), @@ -45,7 +52,7 @@ internal object DiscoveryReportFormatter { add("Unique nodes" to result.uniqueNodes.toString()) add("Direct neighbors" to result.directNeighborCount.toString()) add("Mesh neighbors" to result.meshNeighborCount.toString()) - add("Dwell time" to formatDuration(result.dwellDurationSeconds)) + add("Dwell time" to reportDuration(result.dwellDurationSeconds)) add("Channel utilization" to "${NumberFormatter.format(result.avgChannelUtilization, 1)}%") add("Airtime rate" to "${NumberFormatter.format(result.avgAirtimeRate, 1)}%") add("Packet success" to "${NumberFormatter.format(result.packetSuccessRate, 1)}%") @@ -59,7 +66,7 @@ internal object DiscoveryReportFormatter { } fun formatNodeLine(node: DiscoveredNodeEntity, measurementSystem: MeasurementSystem): String = buildString { - append(node.longName ?: node.shortName ?: "!${node.nodeNum.toString(radix = 16)}") + append(node.longName ?: node.shortName ?: NodeAddress.numToDefaultId(node.nodeNum.toInt())) append(" | ${node.neighborType}") append(" | SNR: ${MetricFormatter.snr(node.snr)}") append(" | RSSI: ${MetricFormatter.rssi(node.rssi)}") diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaverLauncher.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaverLauncher.kt index 44c5af1869..68136ce105 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaverLauncher.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaverLauncher.kt @@ -17,14 +17,24 @@ package org.meshtastic.feature.discovery.export 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 org.meshtastic.core.ui.util.rememberFileExporter -/** - * Returns a launcher that saves [ExportResult.Success] content to the platform's file system. - * - * On Android this opens a SAF document-picker (ACTION_CREATE_DOCUMENT). On Desktop this writes to a user-chosen file - * via a file dialog. - */ -@Composable expect fun rememberExportSaver(): ExportSaverLauncher +/** Returns a launcher that saves [ExportResult.Success] content to a file the user picks. */ +@Composable +fun rememberExportSaver(): ExportSaverLauncher { + var pending by remember { mutableStateOf(null) } + val export = rememberFileExporter(content = { pending?.content }) + return remember(export) { + ExportSaverLauncher { result -> + pending = result + export(result.fileName, result.mimeType) + } + } +} /** Platform-agnostic handle for triggering a file-save from export data. */ fun interface ExportSaverLauncher { diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryHistoryDetailScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryHistoryDetailScreen.kt index d1495a1a9d..e3a9799a60 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryHistoryDetailScreen.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryHistoryDetailScreen.kt @@ -55,11 +55,13 @@ import org.meshtastic.core.resources.discovery_stat_total_dwell_time import org.meshtastic.core.resources.discovery_stat_total_messages import org.meshtastic.core.resources.discovery_stat_unique_nodes import org.meshtastic.core.resources.discovery_view_map +import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.ui.icon.ArrowBack import org.meshtastic.core.ui.icon.Map import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.discovery.DiscoveryHistoryDetailViewModel import org.meshtastic.feature.discovery.ui.component.PresetResultCard +import kotlin.time.DurationUnit @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -139,7 +141,7 @@ private fun SessionMetadataCard(session: DiscoverySessionEntity) { MetadataRow(stringResource(Res.string.discovery_stat_total_messages), session.totalMessages.toString()) MetadataRow( stringResource(Res.string.discovery_stat_total_dwell_time), - formatDuration(session.totalDwellSeconds), + formatDuration(session.totalDwellSeconds, smallest = DurationUnit.MINUTES), ) session.aiSummary?.let { summary -> Spacer(Modifier.height(8.dp)) diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt index 63a5d184ac..ca86b28dc5 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt @@ -66,6 +66,7 @@ import org.meshtastic.core.resources.discovery_stat_total_dwell_time import org.meshtastic.core.resources.discovery_stat_total_unique_nodes import org.meshtastic.core.resources.discovery_summary_not_available import org.meshtastic.core.resources.discovery_view_map +import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.ui.icon.ArrowBack import org.meshtastic.core.ui.icon.Map import org.meshtastic.core.ui.icon.MeshtasticIcons @@ -76,6 +77,7 @@ import org.meshtastic.feature.discovery.export.ExportResult import org.meshtastic.feature.discovery.export.rememberExportSaver import org.meshtastic.feature.discovery.scan.PresetRanking import org.meshtastic.feature.discovery.ui.component.PresetResultCard +import kotlin.time.DurationUnit @Composable fun DiscoverySummaryScreen( @@ -235,7 +237,7 @@ private fun SessionOverviewCard(session: DiscoverySessionEntity) { ) StatRow( label = stringResource(Res.string.discovery_stat_total_dwell_time), - value = formatDuration(session.totalDwellSeconds), + value = formatDuration(session.totalDwellSeconds, smallest = DurationUnit.MINUTES), ) StatRow( label = stringResource(Res.string.discovery_stat_status), @@ -312,10 +314,3 @@ internal fun StatRow(label: String, value: String, modifier: Modifier = Modifier Text(text = value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) } } - -internal fun formatDuration(totalSeconds: Long): String { - val minutes = totalSeconds / 60 - val hours = minutes / 60 - val remainingMinutes = minutes % 60 - return if (hours > 0) "${hours}h ${remainingMinutes}m" else "${minutes}m" -} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/DwellProgressIndicator.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/DwellProgressIndicator.kt index 15427f705e..ba7cc79c6b 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/DwellProgressIndicator.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/DwellProgressIndicator.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.discovery_dwell_progress import org.meshtastic.core.resources.discovery_stat_dwelling_on @@ -39,7 +40,6 @@ import org.meshtastic.core.resources.discovery_time_remaining @Suppress("MagicNumber") private val CONTENT_PADDING = 8.dp -private const val SECONDS_PER_MINUTE = 60L /** Displays dwell progress for a single preset with a countdown timer and linear progress bar. */ @Composable @@ -55,8 +55,8 @@ fun DwellProgressIndicator( } else { 0f } - val minutes = remainingSeconds / SECONDS_PER_MINUTE - val seconds = remainingSeconds % SECONDS_PER_MINUTE + val minutes = remainingSeconds / TimeConstants.SECONDS_PER_MINUTE + val seconds = remainingSeconds % TimeConstants.SECONDS_PER_MINUTE val timeText = "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" val progressDescription = stringResource(Res.string.discovery_dwell_progress, presetName, timeText) diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetResultCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetResultCard.kt index 7252a7d0c1..e0cf04cdf9 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetResultCard.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetResultCard.kt @@ -46,8 +46,9 @@ import org.meshtastic.core.resources.discovery_stat_mesh import org.meshtastic.core.resources.discovery_stat_messages import org.meshtastic.core.resources.discovery_stat_sensor_pkts import org.meshtastic.core.resources.discovery_stat_unique_nodes +import org.meshtastic.core.resources.formatDuration import org.meshtastic.feature.discovery.ui.StatRow -import org.meshtastic.feature.discovery.ui.formatDuration +import kotlin.time.DurationUnit @Composable fun PresetResultCard( @@ -111,7 +112,7 @@ private fun PresetHeader(result: DiscoveryPresetResultEntity, rank: Int?, isTied } } Text( - text = formatDuration(result.dwellDurationSeconds), + text = formatDuration(result.dwellDurationSeconds, smallest = DurationUnit.MINUTES), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportNodeIdTest.kt b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportNodeIdTest.kt new file mode 100644 index 0000000000..ef78929da4 --- /dev/null +++ b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportNodeIdTest.kt @@ -0,0 +1,47 @@ +/* + * 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.discovery.export + +import org.meshtastic.core.common.util.MeasurementSystem +import org.meshtastic.core.database.entity.DiscoveredNodeEntity +import kotlin.test.Test +import kotlin.test.assertTrue + +class DiscoveryReportNodeIdTest { + + @Test + fun `an unnamed node is listed by its padded node id`() { + val line = + DiscoveryReportFormatter.formatNodeLine( + DiscoveredNodeEntity(presetResultId = 1L, nodeNum = 0x1234L), + MeasurementSystem.METRIC, + ) + + assertTrue(line.startsWith("!00001234 |"), line) + } + + @Test + fun `a node number above the signed range keeps its unsigned id`() { + val line = + DiscoveryReportFormatter.formatNodeLine( + DiscoveredNodeEntity(presetResultId = 1L, nodeNum = 0xFFFF_FFFEL), + MeasurementSystem.METRIC, + ) + + assertTrue(line.startsWith("!fffffffe |"), line) + } +} diff --git a/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.jvm.kt b/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.jvm.kt deleted file mode 100644 index 35be6d0956..0000000000 --- a/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/ExportSaver.jvm.kt +++ /dev/null @@ -1,53 +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 org.meshtastic.feature.discovery.export - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import javax.swing.JFileChooser -import javax.swing.filechooser.FileNameExtensionFilter - -@Composable -actual fun rememberExportSaver(): ExportSaverLauncher { - val scope = rememberCoroutineScope() - return ExportSaverLauncher { result -> - scope.launch { - withContext(Dispatchers.IO) { - @Suppress("TooGenericExceptionCaught") - try { - val chooser = - JFileChooser().apply { - dialogTitle = "Save Discovery Report" - selectedFile = File(result.fileName) - val ext = result.fileName.substringAfterLast('.', "txt") - fileFilter = FileNameExtensionFilter("${ext.uppercase()} files", ext) - } - if (chooser.showSaveDialog(null) == JFileChooser.APPROVE_OPTION) { - chooser.selectedFile.writeBytes(result.content) - } - } catch (e: Exception) { - Logger.e(throwable = e) { "Failed to save export file on desktop" } - } - } - } - } -} diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts index a663629a39..3c7079d8d9 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -57,6 +57,9 @@ kotlin { // performUsbUpdate resolves compose-resources strings, whose desktop implementation needs // the skiko-awt runtime to read the system theme. - jvmTest.dependencies { implementation(compose.desktop.currentOs) } + jvmTest.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.ktor.client.mock) + } } } diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt index 8f3907cc29..eb9c98f16f 100644 --- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt +++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt @@ -22,25 +22,14 @@ import android.provider.OpenableColumns import co.touchlab.kermit.Logger import com.eygraber.uri.toAndroidUri import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.request.head -import io.ktor.client.statement.bodyAsText -import io.ktor.http.isSuccess import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.safeCatching -import org.meshtastic.core.model.DeviceHardware import java.io.File -import java.io.FileOutputStream import java.io.IOException import java.io.InputStream -import java.net.URI -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream - -private const val DOWNLOAD_BUFFER_SIZE = 8192 /** SAF provider for physical volumes; the only one a UF2 bootloader drive can appear under. */ private const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents" @@ -52,158 +41,12 @@ private const val PRIMARY_VOLUME_ID = "primary" * Helper class to handle file operations related to firmware updates, such as downloading, copying from URI, and * extracting specific files from Zip archives. */ -@Single +@Single(binds = [FirmwareFileHandler::class]) @Suppress("TooManyFunctions") -class AndroidFirmwareFileHandler(private val context: Context, private val client: HttpClient) : FirmwareFileHandler { - private val tempDir = File(context.cacheDir, "firmware_update") +class AndroidFirmwareFileHandler(private val context: Context, client: HttpClient) : + BaseFirmwareFileHandler(client, File(context.cacheDir, "firmware_update")) { - override fun cleanupAllTemporaryFiles() { - runCatching { - if (tempDir.exists()) { - tempDir.deleteRecursively() - } - tempDir.mkdirs() - } - .onFailure { e -> Logger.w(e) { "Failed to cleanup temp directory" } } - } - - override suspend fun checkUrlExists(url: String): Boolean = withContext(ioDispatcher) { - try { - client.head(url).status.isSuccess() - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "Failed to check URL existence: $url" } - false - } - } - - override suspend fun fetchText(url: String): String? = withContext(ioDispatcher) { - try { - val response = client.get(url) - if (response.status.isSuccess()) response.bodyAsText() else null - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "Failed to fetch text from: $url" } - null - } - } - - override suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): FirmwareArtifact? = - withContext(ioDispatcher) { - val response = - try { - client.get(url) - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "Download failed for $url" } - return@withContext null - } - - if (!response.status.isSuccess()) { - Logger.w { "Download failed: ${response.status.value} for $url" } - return@withContext null - } - - if (!tempDir.exists()) tempDir.mkdirs() - val targetFile = java.io.File(tempDir, fileName) - downloadResponseToFile(response, targetFile, onProgress) - targetFile.toFirmwareArtifact() - } - - override suspend fun extractFirmwareFromZip( - zipFile: FirmwareArtifact, - hardware: DeviceHardware, - fileExtension: String, - preferredFilename: String?, - ): FirmwareArtifact? = withContext(ioDispatcher) { - val localZipFile = zipFile.toLocalFileOrNull() ?: return@withContext null - val target = hardware.effectiveTarget - if (target.isEmpty() && preferredFilename == null) return@withContext null - - val targetLowerCase = target.lowercase() - val preferredFilenameLower = preferredFilename?.lowercase() - val matchingEntries = mutableListOf>() - - if (!tempDir.exists()) tempDir.mkdirs() - - ZipInputStream(localZipFile.inputStream()).use { zipInput -> - var entry = zipInput.nextEntry - while (entry != null) { - val name = entry.name.lowercase() - // File(name).name strips directory components, mitigating ZipSlip attacks - val entryFileName = File(name).name - - val isMatch = - if (preferredFilenameLower != null) { - entryFileName == preferredFilenameLower - } else { - !entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension) - } - - if (isMatch) { - val outFile = File(tempDir, entryFileName) - FileOutputStream(outFile).use { output -> zipInput.copyTo(output) } - matchingEntries.add(entry to outFile) - - if (preferredFilenameLower != null) { - return@withContext outFile.toFirmwareArtifact() - } - } - entry = zipInput.nextEntry - } - } - // Prefer the shortest matching entry name — official release bundles contain one - // matching firmware per target; the heuristic picks the canonical name if multiple match. - matchingEntries.minByOrNull { it.first.name.length }?.second?.toFirmwareArtifact() - } - - override suspend fun extractFirmware( - uri: CommonUri, - hardware: DeviceHardware, - fileExtension: String, - preferredFilename: String?, - ): FirmwareArtifact? = withContext(ioDispatcher) { - val target = hardware.effectiveTarget - if (target.isEmpty() && preferredFilename == null) return@withContext null - - val targetLowerCase = target.lowercase() - val preferredFilenameLower = preferredFilename?.lowercase() - val matchingEntries = mutableListOf>() - - if (!tempDir.exists()) tempDir.mkdirs() - - try { - val platformUri = uri.toAndroidUri() - val inputStream = context.contentResolver.openInputStream(platformUri) ?: return@withContext null - ZipInputStream(inputStream).use { zipInput -> - var entry = zipInput.nextEntry - while (entry != null) { - val name = entry.name.lowercase() - // File(name).name strips directory components, mitigating ZipSlip attacks - val entryFileName = File(name).name - - val isMatch = - if (preferredFilenameLower != null) { - entryFileName == preferredFilenameLower - } else { - !entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension) - } - - if (isMatch) { - val outFile = File(tempDir, entryFileName) - FileOutputStream(outFile).use { output -> zipInput.copyTo(output) } - matchingEntries.add(entry to outFile) - - if (preferredFilenameLower != null) { - return@withContext outFile.toFirmwareArtifact() - } - } - entry = zipInput.nextEntry - } - } - } catch (e: IOException) { - Logger.w(e) { "Failed to extract firmware from URI" } - return@withContext null - } - matchingEntries.minByOrNull { it.first.name.length }?.second?.toFirmwareArtifact() - } + override fun openUri(uri: CommonUri): InputStream? = context.contentResolver.openInputStream(uri.toAndroidUri()) override suspend fun getFileSize(file: FirmwareArtifact): Long = withContext(ioDispatcher) { file.toLocalFileOrNull()?.takeIf { it.exists() }?.length() @@ -213,12 +56,6 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien ?: 0L } - override suspend fun deleteFile(file: FirmwareArtifact) = withContext(ioDispatcher) { - if (!file.isTemporary) return@withContext - val localFile = file.toLocalFileOrNull() ?: return@withContext - if (localFile.exists()) localFile.delete() - } - override suspend fun readBytes(artifact: FirmwareArtifact): ByteArray = withContext(ioDispatcher) { val localFile = artifact.toLocalFileOrNull() if (localFile != null && localFile.exists()) { @@ -230,8 +67,8 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien } override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = withContext(ioDispatcher) { - val inputStream = context.contentResolver.openInputStream(uri.toAndroidUri()) ?: return@withContext null - val tempFile = File(context.cacheDir, "firmware_update/ota_firmware.bin") + val inputStream = openUri(uri) ?: return@withContext null + val tempFile = File(tempDir, "ota_firmware.bin") tempFile.parentFile?.mkdirs() inputStream.use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } tempFile.toFirmwareArtifact() @@ -293,9 +130,6 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien ?: throw IOException("Cannot open artifact: ${artifact.uri}") } - private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean = - org.meshtastic.feature.firmware.isValidFirmwareFile(filename, target, fileExtension) - /** * Accepts only a Storage Access Framework document on a non-primary external volume. * @@ -387,16 +221,4 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien inputStream.use { input -> outputStream.use { output -> input.copyTo(output) } } } - - private fun File.toFirmwareArtifact(): FirmwareArtifact = - FirmwareArtifact(uri = CommonUri.parse(toURI().toString()), fileName = name, isTemporary = true) - - private fun FirmwareArtifact.toLocalFileOrNull(): File? { - val uriString = uri.toString() - return if (uriString.startsWith("file:")) { - runCatching { File(URI(uriString)) }.getOrNull() - } else { - null - } - } } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt index 8aa2339df7..ff888d1a86 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt @@ -117,7 +117,8 @@ interface FirmwareFileHandler { * @param hardware Used to match the correct binary inside the zip. * @param fileExtension The extension to filter for (e.g. ".bin", ".uf2"). * @param preferredFilename Optional exact filename to prefer within the zip. - * @return The extracted [FirmwareArtifact], or `null` if no matching file was found. + * @return The extracted [FirmwareArtifact], or `null` if no file matched or the archive is corrupt or over the + * extraction limits. */ suspend fun extractFirmware( uri: CommonUri, @@ -133,7 +134,8 @@ interface FirmwareFileHandler { * @param hardware Used to match the correct binary inside the zip. * @param fileExtension The extension to filter for (e.g. ".bin", ".uf2"). * @param preferredFilename Optional exact filename to prefer within the zip. - * @return The extracted [FirmwareArtifact], or `null` if no matching file was found. + * @return The extracted [FirmwareArtifact], or `null` if no file matched or the archive is corrupt or over the + * extraction limits. */ suspend fun extractFirmwareFromZip( zipFile: FirmwareArtifact, @@ -151,14 +153,16 @@ interface FirmwareFileHandler { /** * Check whether [filename] is a valid firmware binary for [target] with the expected [fileExtension]. Excludes - * non-firmware binaries that share the same extension (e.g. `littlefs-*`, `bleota*`). + * non-firmware binaries that share the same extension (e.g. `littlefs-*`, `bleota*`), wherever they sit in a zip's + * directories. */ @Suppress("ComplexCondition") // excluded-binary + target/extension guards collapsed to one early-out internal fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean { + val baseName = filename.substringAfterLast('/') if ( - filename.startsWith("littlefs-") || - filename.startsWith("bleota") || - filename.startsWith("mt-") || + baseName.startsWith("littlefs-") || + baseName.startsWith("bleota") || + baseName.startsWith("mt-") || filename.contains(".factory.") || target.isBlank() || !filename.endsWith(fileExtension) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/ThroughputTracker.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/ThroughputTracker.kt index 82b5adcc4d..eef2d08867 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/ThroughputTracker.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/ThroughputTracker.kt @@ -16,10 +16,9 @@ */ package org.meshtastic.feature.firmware.ota +import org.meshtastic.core.model.util.TimeConstants import kotlin.time.TimeSource -private const val MILLIS_PER_SECOND = 1000L - /** * Sliding window throughput tracker to calculate current transfer speed in bytes per second. Adapted from kmp-ble's * DfuProgress throughput tracking. @@ -52,6 +51,6 @@ class ThroughputTracker(private val windowSize: Int = 10, private val timeSource if (durationMs <= 0) return 0 val deltaBytes = byteCounts[newestIdx] - byteCounts[oldestIdx] - return (deltaBytes * MILLIS_PER_SECOND) / durationMs + return (deltaBytes * TimeConstants.MS_PER_SEC) / durationMs } } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt index 4dec220ce8..45751855cf 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/IsValidFirmwareFileTest.kt @@ -69,6 +69,13 @@ class IsValidFirmwareFileTest { assertFalse(isValidFirmwareFile("littlefs-heltec-v3-2.7.17.bin", "heltec-v3", ".bin")) } + @Test + fun `rejects littlefs and bleota images inside a release zip directory`() { + assertFalse(isValidFirmwareFile("esp32s3/littlefs-heltec-v3-2.8.0.abc.bin", "heltec-v3", ".bin")) + assertFalse(isValidFirmwareFile("esp32s3/bleota-heltec-v3-2.8.0.abc.bin", "heltec-v3", ".bin")) + assertTrue(isValidFirmwareFile("esp32s3/firmware-heltec-v3-2.8.0.abc.bin", "heltec-v3", ".bin")) + } + @Test fun `rejects bleota prefix`() { assertFalse(isValidFirmwareFile("bleota-heltec-v3-2.7.17.bin", "heltec-v3", ".bin")) diff --git a/feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/BaseFirmwareFileHandler.kt b/feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/BaseFirmwareFileHandler.kt new file mode 100644 index 0000000000..118f2e37bd --- /dev/null +++ b/feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/BaseFirmwareFileHandler.kt @@ -0,0 +1,166 @@ +/* + * 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.firmware + +import co.touchlab.kermit.Logger +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.head +import io.ktor.client.statement.bodyAsText +import io.ktor.http.isSuccess +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withContext +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.model.DeviceHardware +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.net.URI + +/** + * The [FirmwareFileHandler] work the Android and desktop handlers share: HTTP fetches into [tempDir], and firmware + * extraction through the bounded [extractFirmwareEntry]. Subclasses say how a platform URI is opened. + */ +abstract class BaseFirmwareFileHandler(private val client: HttpClient, protected val tempDir: File) : + FirmwareFileHandler { + + /** Opens [uri] for reading, or returns null when the platform cannot resolve it. */ + protected abstract fun openUri(uri: CommonUri): InputStream? + + override fun cleanupAllTemporaryFiles() { + runCatching { + if (tempDir.exists()) { + tempDir.deleteRecursively() + } + tempDir.mkdirs() + } + .onFailure { e -> Logger.w(e) { "Failed to cleanup temp directory" } } + } + + override suspend fun deleteFile(file: FirmwareArtifact) = withContext(ioDispatcher) { + if (!file.isTemporary) return@withContext + val localFile = file.toLocalFileOrNull() ?: return@withContext + if (localFile.exists()) localFile.delete() + } + + override suspend fun checkUrlExists(url: String): Boolean = withContext(ioDispatcher) { + try { + client.head(url).status.isSuccess() + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "Failed to check URL existence: $url" } + false + } + } + + override suspend fun fetchText(url: String): String? = withContext(ioDispatcher) { + try { + val response = client.get(url) + if (response.status.isSuccess()) response.bodyAsText() else null + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "Failed to fetch text from: $url" } + null + } + } + + override suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): FirmwareArtifact? = + withContext(ioDispatcher) { + // fileName can come from a remote manifest, so it must name a file directly inside tempDir. + val targetFile = File(tempDir, fileName) + if (targetFile.canonicalFile.parentFile != tempDir.canonicalFile) { + Logger.w { "Refusing a download name outside the temp directory: $fileName" } + return@withContext null + } + val response = + try { + client.get(url) + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.w(e) { "Download failed for $url" } + return@withContext null + } + + if (!response.status.isSuccess()) { + Logger.w { "Download failed: ${response.status.value} for $url" } + return@withContext null + } + + if (!tempDir.exists()) tempDir.mkdirs() + downloadResponseToFile(response, targetFile, onProgress) + targetFile.toFirmwareArtifact() + } + + override suspend fun extractFirmware( + uri: CommonUri, + hardware: DeviceHardware, + fileExtension: String, + preferredFilename: String?, + ): FirmwareArtifact? = withContext(ioDispatcher) { + if (hardware.effectiveTarget.isEmpty() && preferredFilename == null) return@withContext null + extractFirmwareEntryOrNull(tempDir, hardware.effectiveTarget, fileExtension, preferredFilename) { + openUri(uri) + } + ?.toFirmwareArtifact() + } + + override suspend fun extractFirmwareFromZip( + zipFile: FirmwareArtifact, + hardware: DeviceHardware, + fileExtension: String, + preferredFilename: String?, + ): FirmwareArtifact? = withContext(ioDispatcher) { + val localZipFile = zipFile.toLocalFileOrNull() ?: return@withContext null + if (hardware.effectiveTarget.isEmpty() && preferredFilename == null) return@withContext null + extractFirmwareEntryOrNull(tempDir, hardware.effectiveTarget, fileExtension, preferredFilename) { + localZipFile.inputStream() + } + ?.toFirmwareArtifact() + } + + protected fun File.toFirmwareArtifact(): FirmwareArtifact = + FirmwareArtifact(uri = CommonUri.parse(toURI().toString()), fileName = name, isTemporary = true) + + protected fun FirmwareArtifact.toLocalFileOrNull(): File? = uri.toLocalFileOrNull() + + protected fun CommonUri.toLocalFileOrNull(): File? = runCatching { + val parsedUri = URI(toString()) + if (parsedUri.scheme == "file") File(parsedUri) else null + } + .getOrNull() +} + +/** A corrupt archive or one past [extractFirmwareEntry]'s limits yields null, so retrieval moves to its fallbacks. */ +private fun extractFirmwareEntryOrNull( + outputDir: File, + target: String, + fileExtension: String, + preferredFilename: String?, + open: () -> InputStream?, +): File? = try { + open()?.let { extractFirmwareEntry(it, outputDir, target, fileExtension, preferredFilename) } +} catch (e: IOException) { + Logger.w(e) { "Failed to extract firmware" } + null +} catch (e: IllegalArgumentException) { + Logger.w(e) { "Firmware archive refused" } + null +} diff --git a/feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/ZipExtraction.kt b/feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/ZipExtraction.kt index a1ec44cc6b..c2fa354316 100644 --- a/feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/ZipExtraction.kt +++ b/feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/ZipExtraction.kt @@ -17,9 +17,14 @@ package org.meshtastic.feature.firmware import java.io.ByteArrayOutputStream +import java.io.File import java.io.FilterInputStream import java.io.InputStream +import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.zip.ZipInputStream +import kotlin.io.path.createTempDirectory /** * Ceiling on a firmware archive as delivered. Real Meshtastic release zips are tens of MB, and the whole archive is @@ -35,6 +40,18 @@ internal const val MAX_FIRMWARE_ZIP_BYTES = 128L * 1024 * 1024 */ internal const val MAX_FIRMWARE_UNCOMPRESSED_BYTES = 96L * 1024 * 1024 +/** + * Ceiling on bytes [extractFirmwareEntry] writes to disk across the entries it keeps. A single firmware image is a few + * MB, so this only needs to stop an entry that inflates without end. + */ +internal const val MAX_FIRMWARE_EXTRACTED_BYTES = 96L * 1024 * 1024 + +/** + * Ceiling on bytes [extractFirmwareEntry] inflates from entries it skips. The largest release archive inflates to + * several hundred MB, so this only needs to stop an entry that inflates without end. + */ +internal const val MAX_FIRMWARE_SKIPPED_BYTES = 2L * 1024 * 1024 * 1024 + /** Ceiling on entry count. A release archive holds a few hundred at most. */ internal const val MAX_FIRMWARE_ZIP_ENTRIES = 4096 @@ -71,14 +88,22 @@ private class LimitedInputStream(delegate: InputStream, private val limit: Long) */ internal fun readAtMost(input: InputStream, limit: Long): ByteArray? { val out = ByteArrayOutputStream() + return copyAtMost(input, out, limit)?.let { out.toByteArray() } +} + +/** + * Copies at most [limit] bytes from [input] to [output], returning the count, or null once the source proves longer. + * Stops reading as soon as the limit is passed, so no more than `limit + `[COPY_BUFFER_SIZE] bytes are ever pulled. + */ +internal fun copyAtMost(input: InputStream, output: OutputStream, limit: Long): Long? { val buffer = ByteArray(COPY_BUFFER_SIZE) var total = 0L while (true) { val read = input.read(buffer) - if (read < 0) return out.toByteArray() + if (read < 0) return total total += read if (total > limit) return null - out.write(buffer, 0, read) + output.write(buffer, 0, read) } } @@ -123,3 +148,113 @@ internal fun extractZipEntriesBounded( } return entries } + +/** + * Streams the firmware image for [target] out of the zip in [input] into [outputDir] and returns the written file, or + * null when no entry matches. With [preferredFilename] the first entry of exactly that name wins; otherwise every match + * is staged and the shortest entry name wins, the canonical image when a bundle carries variants. + * + * Bounded by entry count, bytes written and bytes inflated from skipped entries, not by archive size: a release archive + * runs past 200 MB and 250 entries, while a single firmware image is a few MB. Throws [IllegalArgumentException] when a + * bound is exceeded and [java.io.IOException] on a corrupt archive. Matches are staged in a directory of their own and + * only the winner is moved into [outputDir], so a failed call leaves [outputDir] as it found it. + */ +internal fun extractFirmwareEntry( + input: InputStream, + outputDir: File, + target: String, + fileExtension: String, + preferredFilename: String?, + maxEntries: Int = MAX_FIRMWARE_ZIP_ENTRIES, + maxWrittenBytes: Long = MAX_FIRMWARE_EXTRACTED_BYTES, + maxSkippedBytes: Long = MAX_FIRMWARE_SKIPPED_BYTES, +): File? { + outputDir.mkdirs() + val staging = createTempDirectory(outputDir.toPath(), ".extract").toFile() + try { + val limits = ExtractionLimits(maxEntries, maxWrittenBytes, maxSkippedBytes) + val (entryName, staged) = + ZipInputStream(input).use { zip -> + stageFirmwareEntry(zip, staging, target, fileExtension, preferredFilename, limits) + } ?: return null + // Only the last path segment is kept, so an entry name cannot write outside outputDir. + val outFile = File(outputDir, File(entryName.lowercase()).name) + Files.move(staged.toPath(), outFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + return outFile + } finally { + staging.deleteRecursively() + } +} + +private class ExtractionLimits(val maxEntries: Int, val maxWrittenBytes: Long, val maxSkippedBytes: Long) + +/** Discards what it is given, so a skipped entry can be inflated and counted without being kept. */ +private object DiscardOutputStream : OutputStream() { + override fun write(b: Int) = Unit + + override fun write(b: ByteArray, off: Int, len: Int) = Unit +} + +/** + * Writes each matching entry of [zip] to its own file in [staging] and returns the chosen one with its entry name. + * Unmatched entries are inflated against [ExtractionLimits.maxSkippedBytes] on the way past. + */ +private fun stageFirmwareEntry( + zip: ZipInputStream, + staging: File, + target: String, + fileExtension: String, + preferredFilename: String?, + limits: ExtractionLimits, +): Pair? { + val candidates = mutableListOf>() + var writeRemaining = limits.maxWrittenBytes + var skipRemaining = limits.maxSkippedBytes + var entriesSeen = 0 + var entry = zip.nextEntry + while (entry != null) { + if (!entry.isDirectory) { + entriesSeen++ + require(entriesSeen <= limits.maxEntries) { "Firmware archive has more than ${limits.maxEntries} entries" } + } + if (isFirmwareEntryMatch(entry.name, entry.isDirectory, target, fileExtension, preferredFilename)) { + // Named by position, so entries sharing a basename in different directories stay distinct. + val staged = File(staging, candidates.size.toString()) + writeRemaining -= + staged.outputStream().use { copyAtMost(zip, it, writeRemaining) } + ?: throw IllegalArgumentException( + "Firmware entry expands past the ${limits.maxWrittenBytes}-byte limit", + ) + candidates += entry.name to staged + if (preferredFilename != null) return candidates.last() + } else { + skipRemaining -= + copyAtMost(zip, DiscardOutputStream, skipRemaining) + ?: throw IllegalArgumentException( + "Skipped entries inflate past the ${limits.maxSkippedBytes}-byte limit", + ) + } + entry = zip.nextEntry + } + return candidates.minByOrNull { (name, _) -> name.length } +} + +/** + * Whether zip entry [entryName] is the firmware to extract: exactly [preferredFilename] (ignoring its directory and + * case) when one is given, otherwise a firmware image for [target] with [fileExtension]. Directories never match. + */ +internal fun isFirmwareEntryMatch( + entryName: String, + isDirectory: Boolean, + target: String, + fileExtension: String, + preferredFilename: String?, +): Boolean { + if (isDirectory) return false + val name = entryName.lowercase() + return if (preferredFilename != null) { + File(name).name == preferredFilename.lowercase() + } else { + isValidFirmwareFile(name, target.lowercase(), fileExtension) + } +} diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt index 37a552606f..7e7e76acff 100644 --- a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt +++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt @@ -16,113 +16,29 @@ */ package org.meshtastic.feature.firmware -import co.touchlab.kermit.Logger import io.ktor.client.HttpClient -import io.ktor.client.request.get -import io.ktor.client.request.head -import io.ktor.client.statement.bodyAsText -import io.ktor.http.isSuccess import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.safeCatching -import org.meshtastic.core.model.DeviceHardware import java.io.File -import java.io.FileOutputStream import java.io.IOException +import java.io.InputStream import java.net.URI import java.nio.file.Files import java.nio.file.StandardCopyOption -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream @Suppress("TooManyFunctions") -@Single -class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandler { - private val tempDir = File(System.getProperty("java.io.tmpdir"), "meshtastic/firmware_update") +@Single(binds = [FirmwareFileHandler::class]) +class JvmFirmwareFileHandler(client: HttpClient) : + BaseFirmwareFileHandler(client, File(System.getProperty("java.io.tmpdir"), "meshtastic/firmware_update")) { - override fun cleanupAllTemporaryFiles() { - runCatching { - if (tempDir.exists()) { - tempDir.deleteRecursively() - } - tempDir.mkdirs() - } - .onFailure { e -> Logger.w(e) { "Failed to cleanup temp directory" } } - } - - override suspend fun checkUrlExists(url: String): Boolean = withContext(ioDispatcher) { - try { - client.head(url).status.isSuccess() - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "Failed to check URL existence: $url" } - false - } - } - - override suspend fun fetchText(url: String): String? = withContext(ioDispatcher) { - try { - val response = client.get(url) - if (response.status.isSuccess()) response.bodyAsText() else null - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "Failed to fetch text from: $url" } - null - } - } - - override suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): FirmwareArtifact? = - withContext(ioDispatcher) { - val response = - try { - client.get(url) - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "Download failed for $url" } - return@withContext null - } - - if (!response.status.isSuccess()) { - Logger.w { "Download failed: ${response.status.value} for $url" } - return@withContext null - } - - if (!tempDir.exists()) tempDir.mkdirs() - val targetFile = File(tempDir, fileName) - downloadResponseToFile(response, targetFile, onProgress) - targetFile.toFirmwareArtifact() - } - - override suspend fun extractFirmware( - uri: CommonUri, - hardware: DeviceHardware, - fileExtension: String, - preferredFilename: String?, - ): FirmwareArtifact? = withContext(ioDispatcher) { - val inputFile = uri.toLocalFileOrNull() ?: return@withContext null - extractFromZipFile(inputFile, hardware, fileExtension, preferredFilename) - } - - override suspend fun extractFirmwareFromZip( - zipFile: FirmwareArtifact, - hardware: DeviceHardware, - fileExtension: String, - preferredFilename: String?, - ): FirmwareArtifact? = withContext(ioDispatcher) { - val inputFile = zipFile.toLocalFileOrNull() ?: return@withContext null - extractFromZipFile(inputFile, hardware, fileExtension, preferredFilename) - } + override fun openUri(uri: CommonUri): InputStream? = uri.toLocalFileOrNull()?.inputStream() override suspend fun getFileSize(file: FirmwareArtifact): Long = withContext(ioDispatcher) { file.toLocalFileOrNull()?.takeIf { it.exists() }?.length() ?: 0L } - override suspend fun deleteFile(file: FirmwareArtifact) = withContext(ioDispatcher) { - if (!file.isTemporary) return@withContext - val localFile = file.toLocalFileOrNull() ?: return@withContext - if (localFile.exists()) { - localFile.delete() - } - } - override suspend fun readBytes(artifact: FirmwareArtifact): ByteArray = withContext(ioDispatcher) { val file = artifact.toLocalFileOrNull() ?: throw IOException("Cannot resolve artifact to file: ${artifact.uri}") @@ -154,8 +70,7 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl /** * Fully expands [artifact] into memory, keyed by entry name. * - * Shares [extractZipEntriesBounded] with the Android handler so the two cannot drift — they previously carried - * independent copies of this loop, and only one of them got bounded. + * Shares [extractZipEntriesBounded] with the Android handler, so both apply the same bounds. */ override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map = withContext(ioDispatcher) { @@ -208,62 +123,4 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl } .getOrNull() } - - @Suppress("NestedBlockDepth", "ReturnCount") - private fun extractFromZipFile( - zipFile: File, - hardware: DeviceHardware, - fileExtension: String, - preferredFilename: String?, - ): FirmwareArtifact? { - val target = hardware.effectiveTarget - if (target.isEmpty() && preferredFilename == null) return null - - val targetLowerCase = target.lowercase() - val preferredFilenameLower = preferredFilename?.lowercase() - val matchingEntries = mutableListOf>() - - if (!tempDir.exists()) tempDir.mkdirs() - - ZipInputStream(zipFile.inputStream()).use { zipInput -> - var entry = zipInput.nextEntry - while (entry != null) { - val name = entry.name.lowercase() - // File(name).name strips directory components, mitigating ZipSlip attacks - val entryFileName = File(name).name - val isMatch = - if (preferredFilenameLower != null) { - entryFileName == preferredFilenameLower - } else { - !entry.isDirectory && isValidFirmwareFile(name, targetLowerCase, fileExtension) - } - - if (isMatch) { - val outFile = File(tempDir, entryFileName) - FileOutputStream(outFile).use { output -> zipInput.copyTo(output) } - matchingEntries.add(entry to outFile) - - if (preferredFilenameLower != null) { - return outFile.toFirmwareArtifact() - } - } - entry = zipInput.nextEntry - } - } - return matchingEntries.minByOrNull { it.first.name.length }?.second?.toFirmwareArtifact() - } - - private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean = - org.meshtastic.feature.firmware.isValidFirmwareFile(filename, target, fileExtension) - - private fun File.toFirmwareArtifact(): FirmwareArtifact = - FirmwareArtifact(uri = CommonUri.parse(toURI().toString()), fileName = name, isTemporary = true) - - private fun FirmwareArtifact.toLocalFileOrNull(): File? = uri.toLocalFileOrNull() - - private fun CommonUri.toLocalFileOrNull(): File? = runCatching { - val parsedUri = URI(toString()) - if (parsedUri.scheme == "file") File(parsedUri) else null - } - .getOrNull() } diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandlerTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandlerTest.kt new file mode 100644 index 0000000000..c01fa00be4 --- /dev/null +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandlerTest.kt @@ -0,0 +1,85 @@ +/* + * 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.firmware + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.model.DeviceHardware +import java.io.File +import java.nio.file.Files +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class JvmFirmwareFileHandlerTest { + private val engine = MockEngine { respond(ByteArray(4) { 1 }) } + private val client = HttpClient(engine) + private val handler = JvmFirmwareFileHandler(client) + private val hardware = DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "heltec-v3") + + @AfterTest fun tearDown() = client.close() + + /** A zip with one more entry than extraction allows, none of them firmware, so nothing is written. */ + private fun overLimitZip(): File { + val file = Files.createTempFile("firmware-handler-test", ".zip").toFile().apply { deleteOnExit() } + ZipOutputStream(file.outputStream()).use { zip -> + repeat(MAX_FIRMWARE_ZIP_ENTRIES + 1) { + zip.putNextEntry(ZipEntry("entry$it")) + zip.closeEntry() + } + } + return file + } + + @Test + fun `a downloaded zip over the extraction limits yields null`() = runTest { + val zip = overLimitZip() + val artifact = FirmwareArtifact(uri = CommonUri.parse(zip.toURI().toString()), fileName = zip.name) + + assertNull(handler.extractFirmwareFromZip(artifact, hardware, ".bin")) + } + + @Test + fun `a picked archive over the extraction limits yields null`() = runTest { + val zip = overLimitZip() + + assertNull(handler.extractFirmware(CommonUri.parse(zip.toURI().toString()), hardware, ".bin")) + } + + @Test + fun `a download name outside the temp directory is refused before any request`() = runTest { + assertNull(handler.downloadFile("https://example.invalid/firmware.bin", "../firmware-handler-test.bin") {}) + assertTrue(engine.requestHistory.isEmpty()) + } + + @Test + fun `a plain download name is fetched into the temp directory`() = runTest { + val artifact = handler.downloadFile("https://example.invalid/firmware.bin", "firmware-handler-test.bin") {} + + assertNotNull(artifact) + handler.deleteFile(artifact) + assertEquals(1, engine.requestHistory.size) + } +} diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ZipExtractionTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ZipExtractionTest.kt index d288f2b719..54bd770464 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ZipExtractionTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ZipExtractionTest.kt @@ -18,14 +18,19 @@ package org.meshtastic.feature.firmware import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.File import java.io.FilterInputStream +import java.io.IOException import java.io.InputStream +import java.nio.file.Files import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import kotlin.random.Random import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -212,6 +217,160 @@ class ZipExtractionTest { assertEquals(setOf("same.bin"), entries.keys, "duplicates collapse to one key — that is the bug's premise") } + // ---------- extractFirmwareEntry ---------- + + private fun tempDir(): File = Files.createTempDirectory("zip-extraction-test").toFile().apply { deleteOnExit() } + + @Test + fun `the target firmware is written to the output directory`() { + val zip = + zipOf( + "esp32s3/firmware-heltec-v3-2.8.0.abc.bin" to ByteArray(32) { 7 }, + "esp32s3/firmware-tbeam-s3-core-2.8.0.abc.bin" to ByteArray(16) { 1 }, + ) + val out = tempDir() + + val file = extractFirmwareEntry(ByteArrayInputStream(zip), out, "heltec-v3", ".bin", preferredFilename = null) + + assertEquals(File(out, "firmware-heltec-v3-2.8.0.abc.bin"), file) + assertContentEquals(ByteArray(32) { 7 }, file?.readBytes()) + } + + @Test + fun `the shortest matching entry name wins`() { + val zip = + zipOf( + "firmware-heltec-v3-2.8.0.abc-update.bin" to ByteArray(8) { 2 }, + "firmware-heltec-v3-2.8.0.abc.bin" to ByteArray(8) { 1 }, + ) + + val file = extractFirmwareEntry(ByteArrayInputStream(zip), tempDir(), "heltec-v3", ".bin", null) + + assertEquals("firmware-heltec-v3-2.8.0.abc.bin", file?.name) + } + + @Test + fun `a preferred filename is matched by name alone ignoring directory and case`() { + val zip = zipOf("nrf52840/Firmware-RAK4631-2.8.0.abc-ota.zip" to ByteArray(8) { 4 }) + + val file = + extractFirmwareEntry( + ByteArrayInputStream(zip), + tempDir(), + target = "", + fileExtension = ".zip", + preferredFilename = "firmware-rak4631-2.8.0.abc-ota.zip", + ) + + assertEquals("firmware-rak4631-2.8.0.abc-ota.zip", file?.name) + } + + @Test + fun `no match returns null and writes nothing`() { + val zip = zipOf("firmware-tbeam-2.8.0.abc.bin" to ByteArray(8)) + val out = tempDir() + + assertNull(extractFirmwareEntry(ByteArrayInputStream(zip), out, "heltec-v3", ".bin", null)) + assertEquals(0, out.listFiles()?.size) + } + + @Test + fun `an entry past the write budget is refused without reading the rest and leaves no file`() { + val incompressible = Random(seed = 99).nextBytes(256 * 1024) + val zip = zipOf("firmware-heltec-v3-2.8.0.abc.bin" to incompressible) + val counting = CountingStream(ByteArrayInputStream(zip)) + val out = tempDir() + + assertFailsWith { + extractFirmwareEntry(counting, out, "heltec-v3", ".bin", null, maxWrittenBytes = 4096) + } + assertTrue(counting.bytesRead < 64 * 1024, "read ${counting.bytesRead} of ${zip.size} bytes") + assertEquals(0, out.listFiles()?.size) + } + + @Test + fun `extraction refuses too many entries`() { + val zip = zipOf(*Array(20) { "entry$it" to ByteArray(4) }) + + assertFailsWith { + extractFirmwareEntry(ByteArrayInputStream(zip), tempDir(), "heltec-v3", ".bin", null, maxEntries = 10) + } + } + + @Test + fun `a refused archive leaves none of the matches written before the refusal`() { + val zip = + zipOf("firmware-heltec-v3-2.8.0.abc.bin" to ByteArray(8) { 1 }, *Array(20) { "entry$it" to ByteArray(4) }) + val out = tempDir() + + assertFailsWith { + extractFirmwareEntry(ByteArrayInputStream(zip), out, "heltec-v3", ".bin", null, maxEntries = 10) + } + assertEquals(0, out.listFiles()?.size) + } + + @Test + fun `a refused archive leaves an earlier extraction of the same name in place`() { + val out = tempDir() + val earlier = File(out, "firmware-heltec-v3-2.8.0.abc.bin").apply { writeBytes(ByteArray(8) { 9 }) } + val zip = + zipOf("firmware-heltec-v3-2.8.0.abc.bin" to ByteArray(8) { 1 }, *Array(20) { "entry$it" to ByteArray(4) }) + + assertFailsWith { + extractFirmwareEntry(ByteArrayInputStream(zip), out, "heltec-v3", ".bin", null, maxEntries = 10) + } + assertContentEquals(ByteArray(8) { 9 }, earlier.readBytes()) + } + + @Test + fun `matches sharing a basename return the bytes of the chosen entry`() { + val zip = + zipOf( + "a/firmware-heltec-v3-2.8.0.abc.bin" to ByteArray(8) { 1 }, + "longer/firmware-heltec-v3-2.8.0.abc.bin" to ByteArray(8) { 2 }, + ) + + val file = extractFirmwareEntry(ByteArrayInputStream(zip), tempDir(), "heltec-v3", ".bin", null) + + assertContentEquals(ByteArray(8) { 1 }, file?.readBytes()) + } + + @Test + fun `an unmatched entry that inflates past the skip budget is refused`() { + val zip = zipOf("readme.txt" to ByteArray(64 * 1024)) + + assertFailsWith { + extractFirmwareEntry( + ByteArrayInputStream(zip), + tempDir(), + "heltec-v3", + ".bin", + null, + maxSkippedBytes = 4096, + ) + } + } + + @Test + fun `a truncated archive leaves no partial firmware file`() { + val zip = zipOf("firmware-heltec-v3-2.8.0.abc.bin" to Random(seed = 7).nextBytes(64 * 1024)) + val out = tempDir() + + assertFailsWith { + extractFirmwareEntry(ByteArrayInputStream(zip.copyOf(zip.size / 2)), out, "heltec-v3", ".bin", null) + } + assertEquals(0, out.listFiles()?.size) + } + + @Test + fun `entry matching excludes directories and non-firmware images`() { + assertTrue(isFirmwareEntryMatch("esp32/firmware-heltec-v3-2.8.0.bin", false, "heltec-v3", ".bin", null)) + assertFalse(isFirmwareEntryMatch("esp32/firmware-heltec-v3-2.8.0.bin/", true, "heltec-v3", ".bin", null)) + assertFalse(isFirmwareEntryMatch("littlefs-heltec-v3-2.8.0.bin", false, "heltec-v3", ".bin", null)) + assertFalse(isFirmwareEntryMatch("firmware-heltec-v3-2.8.0.factory.bin", false, "heltec-v3", ".bin", null)) + assertFalse(isFirmwareEntryMatch("x/firmware.uf2/", true, "", ".uf2", preferredFilename = "firmware.uf2")) + } + @Test fun `directory entries do not consume the entry budget`() { val out = ByteArrayOutputStream() diff --git a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainRegionExtractor.kt b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainRegionExtractor.kt index 1ae90f7566..ac62c58b79 100644 --- a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainRegionExtractor.kt +++ b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainRegionExtractor.kt @@ -56,24 +56,14 @@ class TerrainRegionExtractor( } fun download(bounds: GeoBounds, maxZoom: Int): Flow = flow { - val globalZoomRange = 0..minOf(maxZoom, MapterhornEndpoints.GLOBAL_MAX_ZOOM) - val regionalUrl = - if (maxZoom > MapterhornEndpoints.GLOBAL_MAX_ZOOM) MapterhornEndpoints.regionalUrlFor(bounds) else null - val regionalZoomRange = - MapterhornEndpoints.REGIONAL_MIN_ZOOM..minOf(maxZoom, MapterhornEndpoints.REGIONAL_MAX_ZOOM) + val globalZoomRange = globalTerrainZooms(maxZoom) + val regionalZoomRange = regionalTerrainZooms(bounds, maxZoom) + val regionalUrl = regionalZoomRange?.let { MapterhornEndpoints.regionalUrlFor(bounds) } - // Counted cheaply — via TerrainTileMath.tileCountAt's corner arithmetic, O(1) per zoom — before anything is - // materialized. A single regional z6 tile at maxZoom 18 is ~16.7M tiles at z18 alone; flatMap-ing a TileIndex - // per tile before this check runs would allocate that whole list first and risk OOM on exactly the oversized + // Counted from tile corners before anything is materialized. A single regional z6 tile at maxZoom 18 is ~16.7M + // tiles at z18 alone; enumerating before this check would allocate that whole list on exactly the oversized // requests this limit exists to reject. - val globalCount = globalZoomRange.sumOf { zoom -> TerrainTileMath.tileCountAt(zoom, bounds) } - val regionalCount = - if (regionalUrl != null) { - regionalZoomRange.sumOf { zoom -> TerrainTileMath.tileCountAt(zoom, bounds) } - } else { - 0L - } - val totalTiles = globalCount + regionalCount + val totalTiles = terrainTileCount(bounds, maxZoom) if (totalTiles > MAX_TILES) { emit(TerrainDownloadState.Failed(TerrainDownloadFailure.TILE_LIMIT_EXCEEDED)) return@flow @@ -85,12 +75,7 @@ class TerrainRegionExtractor( // Only materialized now that the cheap count above has confirmed it's under MAX_TILES. val globalTiles = globalZoomRange.flatMap { zoom -> TerrainTileMath.tilesAt(zoom, bounds) } - val regionalTiles = - if (regionalUrl != null) { - regionalZoomRange.flatMap { zoom -> TerrainTileMath.tilesAt(zoom, bounds) } - } else { - emptyList() - } + val regionalTiles = regionalZoomRange?.flatMap { zoom -> TerrainTileMath.tilesAt(zoom, bounds) }.orEmpty() val total = totalTiles.toInt() var global = FetchResult(processed = 0, stored = 0) diff --git a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCount.kt b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCount.kt new file mode 100644 index 0000000000..82bd51aec8 --- /dev/null +++ b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCount.kt @@ -0,0 +1,42 @@ +/* + * 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.map.terrain + +/** + * How many tiles [TerrainRegionExtractor.download] fetches for [bounds] up to [maxZoom]: the global tier always, the + * regional tier only where [MapterhornEndpoints.regionalUrlFor] finds an archive. Counted from tile corners, O(1) per + * zoom, so it is safe to call before deciding whether a download fits. + */ +fun terrainTileCount(bounds: GeoBounds, maxZoom: Int): Long { + val regional = regionalTerrainZooms(bounds, maxZoom)?.let { tileCount(bounds, it) } ?: 0L + return tileCount(bounds, globalTerrainZooms(maxZoom)) + regional +} + +/** Zoom levels fetched from the global archive for a download to [maxZoom]. */ +internal fun globalTerrainZooms(maxZoom: Int): IntRange = 0..minOf(maxZoom, MapterhornEndpoints.GLOBAL_MAX_ZOOM) + +/** Zoom levels fetched from the regional archive, or null when [bounds] has none or [maxZoom] stays global. */ +internal fun regionalTerrainZooms(bounds: GeoBounds, maxZoom: Int): IntRange? = + if (maxZoom > MapterhornEndpoints.GLOBAL_MAX_ZOOM && MapterhornEndpoints.regionalUrlFor(bounds) != null) { + MapterhornEndpoints.REGIONAL_MIN_ZOOM..minOf(maxZoom, MapterhornEndpoints.REGIONAL_MAX_ZOOM) + } else { + null + } + +private fun tileCount(bounds: GeoBounds, zooms: IntRange): Long = zooms.sumOf { zoom -> + TerrainTileMath.tileCountAt(zoom, bounds) +} diff --git a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainTileMath.kt b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainTileMath.kt index cb1f07242e..57ddd6e3b0 100644 --- a/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainTileMath.kt +++ b/feature/map-terrain/src/commonMain/kotlin/org/meshtastic/feature/map/terrain/TerrainTileMath.kt @@ -33,10 +33,8 @@ data class TileIndex(val zoom: Int, val x: Int, val y: Int) data class LonLat(val longitude: Double, val latitude: Double) /** - * Standard XYZ/slippy-map Web Mercator tile math, self-contained here (rather than reused from either flavor's own copy - * — `feature/map-maplibre`'s `TileEstimate.kt` and the Google flavor's `WebMercatorTileMath` in the sibling - * `feat/map-google-pmtiles-offline` branch) so this module has no dependency in either direction on flavor-specific - * code — this module is a shared math library, not a consumer of one flavor's app code. + * Standard XYZ/slippy-map Web Mercator tile math. Depends on no map stack, so the terrain extractor and the Google + * flavor's offline maps share it. */ object TerrainTileMath { @@ -111,10 +109,14 @@ object TerrainTileMath { * * Standard inverse spherical Web Mercator — the mirror of [tileAt]'s own `asinh(tan(...))` forward transform. */ - fun lonLatAt(tile: TileIndex, localX: Float, localY: Float): LonLat { - val n = 2.0.pow(tile.zoom) - val x = (tile.x + localX) / n - val y = (tile.y + localY) / n + fun lonLatAt(tile: TileIndex, localX: Float, localY: Float): LonLat = + lonLatAt(tile.zoom, (tile.x + localX).toDouble(), (tile.y + localY).toDouble()) + + /** [lonLatAt] for a point given in fractional tile units at [zoom], e.g. `x = 3.25` is a quarter into column 3. */ + fun lonLatAt(zoom: Int, tileX: Double, tileY: Double): LonLat { + val n = 2.0.pow(zoom) + val x = tileX / n + val y = tileY / n val longitude = x * FULL_TURN_DEGREES - FULL_TURN_DEGREES / 2 val latitudeRadians = atan(sinh(PI * (1.0 - 2.0 * y))) val latitude = latitudeRadians * HALF_TURN_DEGREES / PI diff --git a/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCountTest.kt b/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCountTest.kt new file mode 100644 index 0000000000..6f1033cef1 --- /dev/null +++ b/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileCountTest.kt @@ -0,0 +1,45 @@ +/* + * 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.map.terrain + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TerrainTileCountTest { + + private val tinySeattleBox = GeoBounds(south = 47.6, west = -122.4, north = 47.61, east = -122.39) + private val continentalBox = GeoBounds(south = -60.0, west = -170.0, north = 60.0, east = 170.0) + + @Test + fun `a download that stays global counts one tile per zoom for a box inside one tile`() { + assertEquals(13L, terrainTileCount(tinySeattleBox, maxZoom = MapterhornEndpoints.GLOBAL_MAX_ZOOM)) + } + + @Test + fun `a box inside one regional archive adds the regional zooms`() { + // z13 and z14 cover 2 tiles apiece here, on top of the 13 global ones. + assertEquals(17L, terrainTileCount(tinySeattleBox, maxZoom = 14)) + } + + @Test + fun `a box with no regional archive never counts past the global tier`() { + val globalOnly = terrainTileCount(continentalBox, maxZoom = MapterhornEndpoints.GLOBAL_MAX_ZOOM) + + assertEquals(8_869_485L, globalOnly) + assertEquals(globalOnly, terrainTileCount(continentalBox, maxZoom = MapterhornEndpoints.REGIONAL_MAX_ZOOM)) + } +} diff --git a/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileMathTest.kt b/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileMathTest.kt index 446cea5408..8da9a4a2e0 100644 --- a/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileMathTest.kt +++ b/feature/map-terrain/src/commonTest/kotlin/org/meshtastic/feature/map/terrain/TerrainTileMathTest.kt @@ -36,6 +36,23 @@ class TerrainTileMathTest { assertEquals(TileIndex(1, 1, 1), TerrainTileMath.tileAt(1, latitude = -45.0, longitude = 10.0)) // SE } + @Test + fun `tileAt matches published slippy map tiles`() { + assertEquals(TileIndex(10, 163, 395), TerrainTileMath.tileAt(10, latitude = 37.7749, longitude = -122.4194)) + assertEquals(TileIndex(16, 32745, 21794), TerrainTileMath.tileAt(16, latitude = 51.5007, longitude = -0.1246)) + } + + @Test + fun `lonLatAt returns a tile's northwest corner in fractional tile units`() { + val corner = TerrainTileMath.lonLatAt(zoom = 10, tileX = 163.0, tileY = 395.0) + assertEquals(-122.6953125, corner.longitude, absoluteTolerance = 1e-9) + assertEquals(37.99616267972812, corner.latitude, absoluteTolerance = 1e-9) + + val inside = TerrainTileMath.lonLatAt(zoom = 2, tileX = 3.25, tileY = 1.75) + assertEquals(112.5, inside.longitude, absoluteTolerance = 1e-9) + assertEquals(21.943045533438177, inside.latitude, absoluteTolerance = 1e-9) + } + @Test fun `tilesAt covers a bbox's own corners inclusively`() { val bounds = GeoBounds(south = -1.0, west = -1.0, north = 1.0, east = 1.0) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 38ae55a33f..0e45c88f16 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -112,6 +112,21 @@ open class BaseMapViewModel( .map { nodes -> nodes.filterNot { node -> node.isIgnored } } .stateInWhileSubscribed(initialValue = emptyList()) + // Set by the map provider because this SavedStateHandle is not the Navigation 3 entry's route state. + private val sitePlannerRequestState = SitePlannerRequestState(nodeRepository.nodeDBbyNum) + + /** The node a Site Planner route asked the map to open, until the map consumes it. */ + val sitePlannerRequest: StateFlow = + sitePlannerRequestState.request.stateInWhileSubscribed(initialValue = null) + + fun setSitePlannerNodeNum(nodeNum: Int?) { + sitePlannerRequestState.setNodeNum(nodeNum) + } + + fun consumeSitePlannerRequest(nodeNum: Int) { + sitePlannerRequestState.consume(nodeNum) + } + val waypoints: StateFlow> = packetRepository .getWaypoints() @@ -138,8 +153,9 @@ open class BaseMapViewModel( fun toggleShowWaypointsOnMap() = mapPrefs.updateMapFilters { it.copy(showWaypoints = !it.showWaypoints) } - fun toggleShowPrecisionCircleOnMap() = - mapPrefs.updateMapFilters { it.copy(showPrecisionCircle = !it.showPrecisionCircle) } + fun toggleShowPrecisionCircleOnMap() = mapPrefs.updateMapFilters { + it.copy(showPrecisionCircle = !it.showPrecisionCircle) + } fun toggleOnlyOnline() = mapPrefs.updateMapFilters { it.copy(onlyOnline = !it.onlyOnline) } @@ -183,11 +199,13 @@ open class BaseMapViewModel( fun clearExcludedRoles() = mapPrefs.updateMapFilters { it.copy(excludedRoles = emptySet()) } - fun setLastHeardFilter(filter: LastHeardFilter) = - mapPrefs.updateMapFilters { it.copy(lastHeardSeconds = filter.seconds) } + fun setLastHeardFilter(filter: LastHeardFilter) = mapPrefs.updateMapFilters { + it.copy(lastHeardSeconds = filter.seconds) + } - fun setLastHeardTrackFilter(filter: LastHeardFilter) = - mapPrefs.updateMapFilters { it.copy(lastHeardTrackSeconds = filter.seconds) } + fun setLastHeardTrackFilter(filter: LastHeardFilter) = mapPrefs.updateMapFilters { + it.copy(lastHeardTrackSeconds = filter.seconds) + } open fun getUser(userId: String?) = nodeRepository.getUser(userId ?: org.meshtastic.core.model.NodeAddress.ID_BROADCAST) diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRequestState.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SitePlannerRequestState.kt similarity index 98% rename from androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRequestState.kt rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SitePlannerRequestState.kt index c3e5329912..e20e963fcd 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/map/SitePlannerRequestState.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SitePlannerRequestState.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 org.meshtastic.app.map +package org.meshtastic.feature.map import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow diff --git a/androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelSitePlannerTest.kt similarity index 71% rename from androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt rename to feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelSitePlannerTest.kt index 9d264275b1..d10767a36a 100644 --- a/androidApp/src/testFdroid/kotlin/org/meshtastic/app/map/MapViewModelSitePlannerRequestTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelSitePlannerTest.kt @@ -14,15 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.app.map +package org.meshtastic.feature.map -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test -import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.mock -import io.ktor.client.HttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -31,12 +28,6 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import okio.Path.Companion.toOkioPath -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.repository.PacketRepository @@ -46,67 +37,44 @@ import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeNotificationPrefs import org.meshtastic.core.testing.FakeRadioConfigRepository import org.meshtastic.core.testing.FakeRadioController -import org.meshtastic.feature.map.layers.MapLayersManager -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull -import java.nio.file.Path as NioPath @OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = android.app.Application::class) -class MapViewModelSitePlannerRequestTest { +class BaseMapViewModelSitePlannerTest { private val testDispatcher = StandardTestDispatcher() private val nodeRepository = FakeNodeRepository() - private val packetRepository = mock(MockMode.autofill) - private val networkRepository = mock(MockMode.autofill) - private val mapPrefs = FakeMapPrefs() + private val packetRepository: PacketRepository = mock() + private val networkRepository: NetworkRepository = mock() private val firstNode = Node(num = 11) private val secondNode = Node(num = 22) - private lateinit var httpClient: HttpClient - private lateinit var layersDir: NioPath - private lateinit var mapLayersManager: MapLayersManager - private lateinit var viewModel: MapViewModel + private lateinit var viewModel: BaseMapViewModel - @Before + @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) every { packetRepository.getWaypoints() } returns flowOf(emptyList()) every { networkRepository.networkAvailable } returns flowOf(true) - httpClient = HttpClient() - layersDir = createTempDirectory("map-layers") - mapLayersManager = - MapLayersManager( - dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher), - httpClient = httpClient, - mapPrefs = mapPrefs, - // The real location reads a global application context this test never installs. - layersDir = layersDir.toOkioPath(), - ) - nodeRepository.setNodes(listOf(firstNode, secondNode)) viewModel = - MapViewModel( - mapPrefs = mapPrefs, - packetRepository = packetRepository, + BaseMapViewModel( + mapPrefs = FakeMapPrefs(), nodeRepository = nodeRepository, + packetRepository = packetRepository, radioController = FakeRadioController(), radioConfigRepository = FakeRadioConfigRepository(), notificationPrefs = FakeNotificationPrefs(), - mapLayersManager = mapLayersManager, - savedStateHandle = SavedStateHandle(), localeUnitsProvider = FakeLocaleUnitsProvider(), networkRepository = networkRepository, ) } - @After + @AfterTest fun tearDown() { - httpClient.close() - layersDir.toFile().deleteRecursively() Dispatchers.resetMain() } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassUiState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassUiState.kt index ff17aacc6e..070f13d2b6 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassUiState.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassUiState.kt @@ -36,7 +36,7 @@ data class CompassUiState( val bearing: Float? = null, val distanceText: String? = null, val bearingText: String? = null, - val lastUpdateText: String? = null, + val lastUpdateAgeSeconds: Long? = null, val positionTimeSec: Long? = null, // Epoch seconds for the target position (used for elapsed display) val warnings: List = emptyList(), val errorRadiusText: String? = null, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index ed5a0be9e5..390109b9e6 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -47,8 +47,6 @@ import kotlin.math.sqrt private const val ALIGNMENT_TOLERANCE_DEGREES = 5f private const val FULL_CIRCLE_DEGREES = 360f private const val BEARING_FORMAT = "%.0f°" -private const val SECONDS_PER_HOUR = 3600 -private const val SECONDS_PER_MINUTE = 60 private const val HUNDRED = 100f private const val MILLIMETERS_PER_METER = 1000f @@ -125,7 +123,7 @@ class CompassViewModel( val distanceText = distanceMeters?.toDistanceString(current.displayUnits) val bearingText = bearingDegrees?.let { formatString(BEARING_FORMAT, it) } val isAligned = isAligned(trueHeading, bearingDegrees) - val lastUpdateText = targetPositionTimeSec?.let { formatElapsed(it) } + val lastUpdateAgeSeconds = targetPositionTimeSec?.let { maxOf(0, nowSeconds - it) } val angularErrorDeg = calculateAngularError(positionalAccuracyMeters, distanceMeters) val errorRadiusText = positionalAccuracyMeters?.toInt()?.let { "± ${it.toDistanceString(current.displayUnits)}" } @@ -137,7 +135,7 @@ class CompassViewModel( bearingText = bearingText, warnings = warnings, isAligned = isAligned, - lastUpdateText = lastUpdateText, + lastUpdateAgeSeconds = lastUpdateAgeSeconds, errorRadiusText = errorRadiusText, angularErrorDeg = angularErrorDeg, ) @@ -197,16 +195,6 @@ class CompassViewModel( return (baseHeading + declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES } - private fun formatElapsed(timestampSec: Long): String { - val nowSec = nowSeconds - val diff = maxOf(0, nowSec - timestampSec) - val hours = diff / SECONDS_PER_HOUR - val minutes = (diff % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE - val seconds = diff % SECONDS_PER_MINUTE - // Show a short elapsed string to match iOS behavior and avoid locale/format churn - return "${hours}h ${minutes}m ${seconds}s ago" - } - @Suppress("ReturnCount") private fun calculatePositionalAccuracyMeters(): Float? { val position = targetPositionProto ?: return null diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ChannelInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ChannelInfo.kt deleted file mode 100644 index c248d2f57d..0000000000 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ChannelInfo.kt +++ /dev/null @@ -1,42 +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 org.meshtastic.feature.node.component - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.channel_label -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Tsunami - -@Composable -fun ChannelInfo( - channel: Int, - modifier: Modifier = Modifier, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.Tsunami, - contentDescription = stringResource(Res.string.channel_label), - text = channel.toString(), - contentColor = contentColor, - ) -} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt index d84cf9455e..f92d01b842 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt @@ -64,9 +64,12 @@ import org.meshtastic.core.resources.compass_no_magnetometer import org.meshtastic.core.resources.compass_title import org.meshtastic.core.resources.compass_uncertainty import org.meshtastic.core.resources.compass_uncertainty_unknown +import org.meshtastic.core.resources.duration_ago import org.meshtastic.core.resources.elevation_suffix import org.meshtastic.core.resources.exchange_position +import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.resources.last_position_update +import org.meshtastic.core.ui.component.ElevationInfo import org.meshtastic.core.ui.icon.ErrorOutline import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.MyLocation @@ -149,9 +152,10 @@ fun CompassSheetContent( } } - uiState.lastUpdateText?.let { + uiState.lastUpdateAgeSeconds?.let { ageSeconds -> + val age = stringResource(Res.string.duration_ago, formatDuration(ageSeconds)) Text( - text = stringResource(Res.string.last_position_update) + ": $it", + text = stringResource(Res.string.last_position_update) + ": $age", style = MaterialTheme.typography.bodyMedium, ) // Quick way to re-request a fresh fix without leaving the compass sheet diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt deleted file mode 100644 index e239ab2810..0000000000 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DistanceInfo.kt +++ /dev/null @@ -1,43 +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 org.meshtastic.feature.node.component - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.distance -import org.meshtastic.core.ui.icon.Distance -import org.meshtastic.core.ui.icon.MeshtasticIcons - -@Composable -fun DistanceInfo( - distance: String, - modifier: Modifier = Modifier, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.Distance, - contentDescription = stringResource(Res.string.distance), - label = stringResource(Res.string.distance), - text = distance, - contentColor = contentColor, - ) -} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt deleted file mode 100644 index 2cc985a8c2..0000000000 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/ElevationInfo.kt +++ /dev/null @@ -1,48 +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 org.meshtastic.feature.node.component - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.common.util.MeasurementSystem -import org.meshtastic.core.model.util.toElevationString -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.altitude -import org.meshtastic.core.resources.elevation_suffix -import org.meshtastic.core.ui.icon.Elevation -import org.meshtastic.core.ui.icon.MeshtasticIcons - -@Composable -fun ElevationInfo( - modifier: Modifier = Modifier, - altitude: Int, - system: MeasurementSystem, - suffix: String = stringResource(Res.string.elevation_suffix), - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.Elevation, - contentDescription = stringResource(Res.string.altitude), - label = stringResource(Res.string.altitude), - text = altitude.toElevationString(system) + " " + suffix, - contentColor = contentColor, - ) -} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt deleted file mode 100644 index 474e8baf2f..0000000000 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/HopsInfo.kt +++ /dev/null @@ -1,39 +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 org.meshtastic.feature.node.component - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.hops_away -import org.meshtastic.core.ui.icon.HopCount -import org.meshtastic.core.ui.icon.MeshtasticIcons - -@Composable -fun HopsInfo(hops: Int, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.HopCount, - contentDescription = stringResource(Res.string.hops_away), - label = stringResource(Res.string.hops_away), - text = hops.toString(), - contentColor = contentColor, - ) -} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/IconInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/IconInfo.kt deleted file mode 100644 index 47ddcb9aeb..0000000000 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/IconInfo.kt +++ /dev/null @@ -1,61 +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 org.meshtastic.feature.node.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.TextStyle -import androidx.compose.ui.unit.dp - -private const val SIZE_ICON = 20 - -@Composable -fun IconInfo( - icon: ImageVector, - contentDescription: String, - modifier: Modifier = Modifier, - label: String? = null, - text: String? = null, - style: TextStyle = MaterialTheme.typography.labelMedium, - contentColor: Color = MaterialTheme.colorScheme.onSurface, - content: @Composable () -> Unit = {}, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - Icon( - modifier = Modifier.size(SIZE_ICON.dp), - imageVector = icon, - contentDescription = contentDescription, - tint = contentColor, - ) - label?.let { Text(text = it, style = style, color = contentColor) } - text?.let { Text(text = it, style = style, color = contentColor) } - content() - } -} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt deleted file mode 100644 index 7f977b1cac..0000000000 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt +++ /dev/null @@ -1,45 +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 org.meshtastic.feature.node.component - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import org.jetbrains.compose.resources.stringResource -import org.jetbrains.compose.resources.vectorResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.ic_antenna -import org.meshtastic.core.resources.node_sort_last_heard -import org.meshtastic.core.ui.util.formatAgo - -@Composable -fun LastHeardInfo( - modifier: Modifier = Modifier, - lastHeard: Int, - showLabel: Boolean = true, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = vectorResource(Res.drawable.ic_antenna), - contentDescription = stringResource(Res.string.node_sort_last_heard), - label = if (showLabel) stringResource(Res.string.node_sort_last_heard) else null, - text = formatAgo(lastHeard), - contentColor = contentColor, - ) -} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index f730b27cb4..1edb22398a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -61,7 +61,6 @@ import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.NodeSecurityIndicator -import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.a11y_label_value import org.meshtastic.core.resources.copy @@ -69,6 +68,7 @@ import org.meshtastic.core.resources.details import org.meshtastic.core.resources.encryption_error import org.meshtastic.core.resources.encryption_error_text import org.meshtastic.core.resources.error +import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.resources.hops_away import org.meshtastic.core.resources.no_public_key import org.meshtastic.core.resources.no_public_key_text @@ -290,7 +290,7 @@ private fun UserAndUptimeRow(node: Node) { if (uptimeSeconds != null && uptimeSeconds > 0) { InfoItem( label = stringResource(Res.string.uptime), - value = formatUptime(uptimeSeconds), + value = formatDuration(uptimeSeconds.toLong()), icon = MeshtasticIcons.ArrowCircleUp, modifier = Modifier.weight(1f), ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt deleted file mode 100644 index ac11d9a887..0000000000 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetryInfo.kt +++ /dev/null @@ -1,200 +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 . - */ -@file:Suppress("TooManyFunctions") - -package org.meshtastic.feature.node.component - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.env_metrics_log -import org.meshtastic.core.resources.hardware_model -import org.meshtastic.core.resources.humidity -import org.meshtastic.core.resources.iaq -import org.meshtastic.core.resources.node_id -import org.meshtastic.core.resources.pax -import org.meshtastic.core.resources.pax_metrics_log -import org.meshtastic.core.resources.role -import org.meshtastic.core.resources.soil_moisture -import org.meshtastic.core.resources.soil_temperature -import org.meshtastic.core.resources.temperature -import org.meshtastic.core.ui.icon.AirQuality -import org.meshtastic.core.ui.icon.ElectricPower -import org.meshtastic.core.ui.icon.HardwareModel -import org.meshtastic.core.ui.icon.Humidity -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.NodeId -import org.meshtastic.core.ui.icon.PeopleCount -import org.meshtastic.core.ui.icon.Role -import org.meshtastic.core.ui.icon.SoilMoisture -import org.meshtastic.core.ui.icon.Temperature - -@Composable -fun TemperatureInfo( - temp: String, - modifier: Modifier = Modifier, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.Temperature, - contentDescription = stringResource(Res.string.env_metrics_log), - label = stringResource(Res.string.temperature), - text = temp, - contentColor = contentColor, - ) -} - -@Composable -fun HumidityInfo( - humidity: String, - modifier: Modifier = Modifier, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.Humidity, - contentDescription = stringResource(Res.string.env_metrics_log), - label = stringResource(Res.string.humidity), - text = humidity, - contentColor = contentColor, - ) -} - -@Composable -fun SoilTemperatureInfo( - temp: String, - modifier: Modifier = Modifier, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.SoilMoisture, - contentDescription = stringResource(Res.string.env_metrics_log), - label = stringResource(Res.string.soil_temperature), - text = temp, - contentColor = contentColor, - ) -} - -@Composable -fun SoilMoistureInfo( - moisture: String, - modifier: Modifier = Modifier, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.SoilMoisture, - contentDescription = stringResource(Res.string.env_metrics_log), - label = stringResource(Res.string.soil_moisture), - text = moisture, - contentColor = contentColor, - ) -} - -@Composable -fun PaxcountInfo( - pax: String, - modifier: Modifier = Modifier, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.PeopleCount, - contentDescription = stringResource(Res.string.pax_metrics_log), - label = stringResource(Res.string.pax), - text = pax, - contentColor = contentColor, - ) -} - -@Composable -fun AirQualityInfo( - iaq: String, - modifier: Modifier = Modifier, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.AirQuality, - contentDescription = stringResource(Res.string.env_metrics_log), - label = stringResource(Res.string.iaq), - text = iaq, - contentColor = contentColor, - ) -} - -@Composable -fun PowerInfo( - value: String, - modifier: Modifier = Modifier, - label: String? = null, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.ElectricPower, - contentDescription = stringResource(Res.string.env_metrics_log), - label = label, - text = value, - contentColor = contentColor, - ) -} - -@Composable -fun HardwareInfo( - hwModel: String, - modifier: Modifier = Modifier, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.HardwareModel, - contentDescription = stringResource(Res.string.hardware_model), - text = hwModel, - style = MaterialTheme.typography.labelSmall, - contentColor = contentColor, - ) -} - -@Composable -fun RoleInfo(role: String, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.Role, - contentDescription = stringResource(Res.string.role), - text = role, - style = MaterialTheme.typography.labelSmall, - contentColor = contentColor, - ) -} - -@Composable -fun NodeIdInfo(id: String, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.NodeId, - contentDescription = stringResource(Res.string.node_id), - text = id, - style = MaterialTheme.typography.labelSmall, - contentColor = contentColor, - ) -} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 4a7cc5a40a..f359795b18 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -62,7 +62,6 @@ import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC -import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.air_util_definition import org.meshtastic.core.resources.air_utilization @@ -74,6 +73,7 @@ import org.meshtastic.core.resources.device_metrics_log import org.meshtastic.core.resources.device_metrics_numeric_value import org.meshtastic.core.resources.device_metrics_percent_value import org.meshtastic.core.resources.device_metrics_voltage_value +import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.resources.uptime import org.meshtastic.core.resources.voltage import org.meshtastic.core.ui.component.MaterialBatteryInfo @@ -486,7 +486,11 @@ private fun DeviceMetricsCard( } Text( text = - formatString(labelValueTemplate, uptimeLabel, formatUptime(deviceMetrics?.uptime_seconds ?: 0)), + formatString( + labelValueTemplate, + uptimeLabel, + formatDuration((deviceMetrics?.uptime_seconds ?: 0).toLong()), + ), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt index 1da33a78a5..6e3c10efe6 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt @@ -55,9 +55,9 @@ import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC -import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.disk_free_indexed +import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.resources.free_memory import org.meshtastic.core.resources.host_metrics_log import org.meshtastic.core.resources.load_indexed @@ -161,7 +161,7 @@ private fun HostMetricsCardContent(time: String, hostMetrics: org.meshtastic.pro Spacer(modifier = Modifier.height(8.dp)) hostMetrics?.uptime_seconds?.let { - LogLine(label = stringResource(Res.string.uptime), value = formatUptime(it)) + LogLine(label = stringResource(Res.string.uptime), value = formatDuration(it.toLong())) } hostMetrics?.freemem_bytes?.let { LogLine(label = stringResource(Res.string.free_memory), value = formatBytes(it)) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index d88c19e03a..d650118b79 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -52,9 +52,9 @@ import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC -import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.ble_devices +import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.resources.no_pax_metrics_logs import org.meshtastic.core.resources.pax import org.meshtastic.core.resources.pax_ble_format @@ -66,9 +66,6 @@ import org.meshtastic.core.resources.pax_wifi_format import org.meshtastic.core.resources.pax_wifi_marker import org.meshtastic.core.resources.uptime import org.meshtastic.core.resources.wifi_devices -import org.meshtastic.core.ui.component.IconInfo -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.PeopleCount import org.meshtastic.core.ui.theme.GraphColors.Orange import org.meshtastic.core.ui.theme.GraphColors.Purple import org.meshtastic.proto.Paxcount as ProtoPaxcount @@ -249,21 +246,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni ) } -@Composable -fun PaxcountInfo( - pax: String, - modifier: Modifier = Modifier, - contentColor: Color = MaterialTheme.colorScheme.onSurface, -) { - IconInfo( - modifier = modifier, - icon = MeshtasticIcons.PeopleCount, - contentDescription = stringResource(Res.string.pax_metrics_log), - text = pax, - contentColor = contentColor, - ) -} - @Composable fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClick: () -> Unit) { SelectableMetricCard(isSelected = isSelected, onClick = onClick) { @@ -297,7 +279,7 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClic } Text( - text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime), + text = stringResource(Res.string.uptime) + ": " + formatDuration(pax.uptime.toLong()), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.End, ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index 8376d02bf9..980d2e3bbc 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -58,12 +58,12 @@ import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.noiseFloorOrNull import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC -import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.model.util.rxTimeOrNull import org.meshtastic.core.model.util.snrOrNull import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.busy_noise_floor import org.meshtastic.core.resources.clear +import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.resources.local_stats_bad import org.meshtastic.core.resources.local_stats_nodes import org.meshtastic.core.resources.local_stats_noise @@ -547,7 +547,11 @@ private fun LocalStatsCard(telemetry: Telemetry, isSelected: Boolean, onClick: ( style = MaterialTheme.typography.labelLarge, ) Text( - text = stringResource(Res.string.local_stats_uptime, formatUptime(localStats?.uptime_seconds ?: 0)), + text = + stringResource( + Res.string.local_stats_uptime, + formatDuration((localStats?.uptime_seconds ?: 0).toLong()), + ), style = MaterialTheme.typography.labelLarge, ) } diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 9875893742..5e76b76a9c 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -62,10 +62,8 @@ import org.meshtastic.core.resources.help_and_documentation import org.meshtastic.core.resources.import_configuration import org.meshtastic.core.resources.node_layout_section_title import org.meshtastic.core.resources.preferences_language -import org.meshtastic.core.resources.remotely_administrating import org.meshtastic.core.resources.wifi_devices 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.icon.Device import org.meshtastic.core.ui.icon.FilterList @@ -81,6 +79,7 @@ import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.PermissionsSettingsContent import org.meshtastic.feature.settings.component.PersistenceSettingsContent import org.meshtastic.feature.settings.component.PrivacySettingsContent +import org.meshtastic.feature.settings.component.RadioAdminAppBar import org.meshtastic.feature.settings.component.ThemePickerDialog import org.meshtastic.feature.settings.component.UnitsOption import org.meshtastic.feature.settings.component.UnitsPickerDialog @@ -207,21 +206,16 @@ fun SettingsScreen( topBar = { // Show back arrow when remotely administering (caller supplies onBack and we're not on the local node). val showBack = onBack != null && !state.isLocal - MainAppBar( + RadioAdminAppBar( title = stringResource(Res.string.bottom_nav_settings), - subtitle = - if (state.isLocal) { - ourNode?.user?.long_name - } else { - val remoteName = destNode?.user?.long_name ?: "" - stringResource(Res.string.remotely_administrating, remoteName) - }, + isLocal = state.isLocal, + destNode = destNode, + onNavigateUp = { onBack?.invoke() }, + localSubtitle = ourNode?.user?.long_name, ourNode = ourNode, + onClickChip = { node -> onClickNodeChip(node.num) }, showNodeChip = ourNode != null && isConnected && state.isLocal, canNavigateUp = showBack, - onNavigateUp = { onBack?.invoke() }, - actions = {}, - onClickChip = { node -> onClickNodeChip(node.num) }, ) }, ) { paddingValues -> diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt index 6bf098b0f5..61182bb1fe 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt @@ -22,37 +22,23 @@ import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.about import org.meshtastic.core.resources.app_notifications -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.icon.AppSettingsAlt import org.meshtastic.core.ui.icon.ChevronRight import org.meshtastic.core.ui.icon.Info -import org.meshtastic.core.ui.icon.Memory import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Notifications import org.meshtastic.core.ui.icon.WavingHand 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 @@ -114,50 +100,6 @@ fun AppInfoSection( } } -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( - hiddenFeaturesUnlocked: Boolean, - appVersionName: String, - onUnlockHiddenFeatures: () -> 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 - onUnlockHiddenFeatures() - scope.launch { context.showToast(Res.string.modules_unlocked) } - } - } - } -} - @Preview(showBackground = true) @Composable fun AppInfoSectionPreview() { diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index 4c3b76081e..d5d8c91539 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -16,63 +16,9 @@ */ package org.meshtastic.feature.settings.debugging -import android.content.Context -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalContext import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.meshtastic.core.common.util.ioDispatcher -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.debug_export_failed -import org.meshtastic.core.resources.debug_logs_exported -import org.meshtastic.core.ui.util.showToast -import java.io.OutputStreamWriter -import java.nio.charset.StandardCharsets import java.util.concurrent.TimeUnit -@Composable -actual fun rememberLogExporter(contentProvider: suspend () -> String): (fileName: String) -> Unit { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val exportLogsLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { createdUri -> - if (createdUri != null) { - scope.launch { exportTextToUri(context, createdUri, contentProvider()) } - } - } - return { fileName -> exportLogsLauncher.launch(fileName) } -} - -private suspend fun exportTextToUri(context: Context, targetUri: Uri, content: String) = withContext(ioDispatcher) { - try { - if (content.isBlank()) { - withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") } - Logger.w { "Log export aborted: no content" } - return@withContext - } - val stream = context.contentResolver.openOutputStream(targetUri) - if (stream == null) { - Logger.w { "Log export aborted: could not open output stream for $targetUri" } - withContext(Dispatchers.Main) { - context.showToast(Res.string.debug_export_failed, "Could not open file") - } - return@withContext - } - stream.use { os -> OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer -> writer.write(content) } } - Logger.i { "Logs exported successfully to $targetUri" } - withContext(Dispatchers.Main) { context.showToast(Res.string.debug_logs_exported) } - } catch (e: java.io.IOException) { - Logger.e(e) { "Failed to export logs to URI: $targetUri" } - withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, e.message ?: "") } - } -} - /** * Dumps this app's own logcat, filtered to our process id via `--pid` (API 24+, minSdk is 26). Without READ_LOGS the OS * already limits us to our own entries, but `--pid` guarantees it even if that permission is ever granted (e.g. via adb diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt deleted file mode 100644 index a28a576788..0000000000 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +++ /dev/null @@ -1,51 +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 org.meshtastic.feature.settings.tak - -import android.content.Context -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalContext -import co.touchlab.kermit.Logger -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.meshtastic.core.common.util.ioDispatcher - -@Composable -actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val exportLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { createdUri -> - if (createdUri != null) { - scope.launch { exportZipToUri(context, createdUri, dataPackageProvider()) } - } - } - return { fileName -> exportLauncher.launch(fileName) } -} - -private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) { - try { - context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) } - Logger.i { "TAK data package exported successfully to $targetUri" } - } catch (e: java.io.IOException) { - Logger.e(e) { "Failed to export TAK data package to URI: $targetUri" } - } -} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt index 12943e3469..51869f046b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt @@ -44,10 +44,9 @@ import org.meshtastic.core.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.component.ExpressiveSection +import org.meshtastic.feature.settings.component.RadioAdminAppBar import org.meshtastic.feature.settings.radio.AdminRoute import org.meshtastic.feature.settings.radio.RadioConfigState import org.meshtastic.feature.settings.radio.RadioConfigViewModel @@ -66,21 +65,11 @@ fun AdministrationScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { Box(modifier = Modifier.fillMaxSize()) { Scaffold( topBar = { - MainAppBar( + RadioAdminAppBar( 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, + isLocal = state.isLocal, + destNode = destNode, onNavigateUp = onBack, - actions = {}, - onClickChip = {}, ) }, ) { paddingValues -> diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt index 0457daca41..d7d9ee29e9 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt @@ -32,10 +32,9 @@ import org.jetbrains.compose.resources.vectorResource 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.component.ExpressiveSection +import org.meshtastic.feature.settings.component.RadioAdminAppBar import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.radio.RadioConfigViewModel @@ -46,21 +45,11 @@ fun DeviceConfigurationScreen(viewModel: RadioConfigViewModel, onBack: () -> Uni Scaffold( topBar = { - MainAppBar( + RadioAdminAppBar( 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, + isLocal = state.isLocal, + destNode = destNode, onNavigateUp = onBack, - actions = {}, - onClickChip = {}, ) }, ) { paddingValues -> diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt index b279604a78..b4c7ed0467 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt @@ -33,10 +33,9 @@ import org.jetbrains.compose.resources.vectorResource 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.component.ExpressiveSection +import org.meshtastic.feature.settings.component.RadioAdminAppBar import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.feature.settings.radio.RadioConfigViewModel @@ -62,21 +61,11 @@ fun ModuleConfigurationScreen( Scaffold( topBar = { - MainAppBar( + RadioAdminAppBar( 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, + isLocal = state.isLocal, + destNode = destNode, onNavigateUp = onBack, - actions = {}, - onClickChip = {}, ) }, ) { paddingValues -> diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/AppVersionButton.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/AppVersionButton.kt new file mode 100644 index 0000000000..dbf11491e9 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/AppVersionButton.kt @@ -0,0 +1,82 @@ +/* + * 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.component + +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 kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.app_version +import org.meshtastic.core.resources.modules_already_unlocked +import org.meshtastic.core.resources.modules_unlocked +import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.icon.Memory +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.util.rememberShowToastResource +import kotlin.time.Duration.Companion.seconds + +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. + +/** The app version row, which unlocks hidden features after [UNLOCK_CLICK_COUNT] quick taps. */ +@Composable +internal fun AppVersionButton( + hiddenFeaturesUnlocked: Boolean, + appVersionName: String, + onUnlockHiddenFeatures: () -> Unit, +) { + val scope = rememberCoroutineScope() + val showToast = rememberShowToastResource() + var clickCount by remember { mutableIntStateOf(0) } + + LaunchedEffect(clickCount) { + if (clickCount in 1.. { + clickCount = 0 + scope.launch { showToast(Res.string.modules_already_unlocked) } + } + + clickCount == UNLOCK_CLICK_COUNT -> { + clickCount = 0 + onUnlockHiddenFeatures() + scope.launch { showToast(Res.string.modules_unlocked) } + } + } + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/RadioAdminAppBar.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/RadioAdminAppBar.kt new file mode 100644 index 0000000000..5eab39dfa6 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/RadioAdminAppBar.kt @@ -0,0 +1,60 @@ +/* + * 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.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.Node +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.remotely_administrating +import org.meshtastic.core.ui.component.MainAppBar + +/** + * App bar for a screen that configures either the connected node or a remote one: while [isLocal] the subtitle is + * [localSubtitle], otherwise it names [destNode] as the node being administered. + */ +@Composable +internal fun RadioAdminAppBar( + title: String, + isLocal: Boolean, + destNode: Node?, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier, + localSubtitle: String? = destNode?.user?.long_name, + ourNode: Node? = null, + onClickChip: (Node) -> Unit = {}, + showNodeChip: Boolean = false, + canNavigateUp: Boolean = true, +) { + MainAppBar( + modifier = modifier, + title = title, + subtitle = + if (isLocal) { + localSubtitle + } else { + stringResource(Res.string.remotely_administrating, destNode?.user?.long_name.orEmpty()) + }, + ourNode = ourNode, + showNodeChip = showNodeChip, + canNavigateUp = canNavigateUp, + onNavigateUp = onNavigateUp, + onClickChip = onClickChip, + actions = {}, + ) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 6d494ba813..1ddb175b0b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -36,10 +36,10 @@ import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowInstant -import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.util.decodeOrNull import org.meshtastic.core.model.util.toReadableString @@ -363,7 +363,7 @@ class DebugViewModel( val placeholder = "___RELAY_NODE___" if (relayNode != 0) { - Packet.getRelayNode(relayNode, nodeList, myNodeNum)?.let { node -> + Node.getRelayNode(relayNode, nodeList, myNodeNum)?.let { node -> val relayId = node.user.id val relayName = node.user.long_name // Wire's toString prints `relay_node=245`; rows stored before the Wire @@ -412,13 +412,11 @@ class DebugViewModel( if (!regex.containsMatchIn(this)) return false regex.findAll(this).toList().asReversed().forEach { val idx = it.range.last + 1 - insert(idx, " (${nodeId.toHex(8)})") + insert(idx, " (${NodeAddress.numToDefaultId(nodeId)})") } return true } - private fun Int.toHex(length: Int): String = "!${this.toUInt().toString(16).padStart(length, '0')}" - fun requestDeleteAllLogs() { alertManager.showAlert( titleRes = Res.string.debug_clear, @@ -441,7 +439,7 @@ class DebugViewModel( val presetFilters: List get() = buildList { // Our address if available - nodeRepository.myNodeInfo.value?.myNodeNum?.let { add(it.toHex(8)) } + nodeRepository.myNodeInfo.value?.myNodeNum?.let { add(NodeAddress.numToDefaultId(it)) } // broadcast add("!ffffffff") // decoded @@ -534,7 +532,7 @@ class DebugViewModel( private fun formatNodeWithShortName(nodeNum: Int): String { val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user val shortName = user?.short_name?.takeIf { it.isNotEmpty() } ?: "" - val nodeId = nodeNum.toHex(8) + val nodeId = NodeAddress.numToDefaultId(nodeNum) return if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExportLauncher.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExportLauncher.kt new file mode 100644 index 0000000000..114fca4f65 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExportLauncher.kt @@ -0,0 +1,59 @@ +/* + * 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.debugging + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.debug_export_failed +import org.meshtastic.core.resources.debug_logs_exported +import org.meshtastic.core.resources.getStringSuspend +import org.meshtastic.core.ui.util.rememberFileExporter +import org.meshtastic.core.ui.util.rememberShowToast + +/** + * Remembers a launcher that writes [contentProvider]'s text to a user-chosen file and toasts the outcome. Blank text is + * reported as a failure rather than written as an empty file. + */ +@Composable +fun rememberLogExporter(contentProvider: suspend () -> String): (fileName: String) -> Unit { + val showToast = rememberShowToast() + var requestedFileName by rememberSaveable { mutableStateOf("") } + val export = + rememberFileExporter( + content = { contentProvider().takeIf { it.isNotBlank() }?.encodeToByteArray() }, + onResult = { exported -> + showToast( + if (exported) { + getStringSuspend(Res.string.debug_logs_exported) + } else { + getStringSuspend(Res.string.debug_export_failed, requestedFileName) + }, + ) + }, + ) + return remember(export) { + { fileName -> + requestedFileName = fileName + export(fileName, "text/plain") + } + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index 82b8286934..cd0d7c3041 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -16,11 +16,6 @@ */ package org.meshtastic.feature.settings.debugging -import androidx.compose.runtime.Composable - -/** Remembers a launcher that writes [contentProvider]'s text to a user-chosen file. */ -@Composable expect fun rememberLogExporter(contentProvider: suspend () -> String): (fileName: String) -> Unit - /** * The app's own logs: Android logcat (filtered to this process) on Android, an in-memory Kermit buffer on desktop. * Empty on platforms with no log capture (currently iOS). diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt index 79bf25320a..1dcc7db393 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownDialog.kt @@ -16,19 +16,11 @@ */ package org.meshtastic.feature.settings.lockdown -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -38,9 +30,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.service.LockdownState @@ -48,23 +37,12 @@ import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.disconnect import org.meshtastic.core.resources.lockdown_backoff -import org.meshtastic.core.resources.lockdown_boots_remaining -import org.meshtastic.core.resources.lockdown_confirm_passphrase import org.meshtastic.core.resources.lockdown_enter_passphrase -import org.meshtastic.core.resources.lockdown_hide_passphrase -import org.meshtastic.core.resources.lockdown_hours_until_expiry import org.meshtastic.core.resources.lockdown_incorrect_passphrase import org.meshtastic.core.resources.lockdown_lock_reason import org.meshtastic.core.resources.lockdown_passphrase -import org.meshtastic.core.resources.lockdown_passphrases_do_not_match -import org.meshtastic.core.resources.lockdown_session_minutes -import org.meshtastic.core.resources.lockdown_session_minutes_help import org.meshtastic.core.resources.lockdown_set_passphrase -import org.meshtastic.core.resources.lockdown_show_passphrase import org.meshtastic.core.resources.lockdown_submit -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Visibility -import org.meshtastic.core.ui.icon.VisibilityOff /** * Non-dismissable lockdown authentication dialog. @@ -103,9 +81,8 @@ fun LockdownDialog( val title = stringResource(if (isProvisioning) Res.string.lockdown_set_passphrase else Res.string.lockdown_enter_passphrase) val inBackoff = lockdownState is LockdownState.UnlockBackoff - val passphraseValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN val confirmValid = !isProvisioning || passphrase == confirmPassphrase - val isValid = passphraseValid && confirmValid && !inBackoff + val isValid = isValidLockdownPassphrase(passphrase) && confirmValid && !inBackoff AlertDialog( onDismissRequest = {}, // Non-dismissable @@ -139,89 +116,30 @@ fun LockdownDialog( else -> {} } - OutlinedTextField( + LockdownPassphraseField( value = passphrase, - onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) passphrase = it }, - label = { Text(stringResource(Res.string.lockdown_passphrase)) }, - singleLine = true, - visualTransformation = - if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = - if (passwordVisible) { - MeshtasticIcons.VisibilityOff - } else { - MeshtasticIcons.Visibility - }, - contentDescription = - stringResource( - if (passwordVisible) { - Res.string.lockdown_hide_passphrase - } else { - Res.string.lockdown_show_passphrase - }, - ), - ) - } - }, - modifier = Modifier.fillMaxWidth(), + onValueChange = { passphrase = it }, + label = stringResource(Res.string.lockdown_passphrase), + passwordVisible = passwordVisible, + onToggleVisibility = { passwordVisible = !passwordVisible }, ) if (isProvisioning) { Spacer(modifier = Modifier.height(SPACING_DP.dp)) - OutlinedTextField( + LockdownConfirmPassphraseField( value = confirmPassphrase, - onValueChange = { - if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) confirmPassphrase = it - }, - label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - isError = confirmPassphrase.isNotEmpty() && passphrase != confirmPassphrase, - supportingText = - if (confirmPassphrase.isNotEmpty() && passphrase != confirmPassphrase) { - { Text(stringResource(Res.string.lockdown_passphrases_do_not_match)) } - } else { - null - }, - modifier = Modifier.fillMaxWidth(), + onValueChange = { confirmPassphrase = it }, + matches = passphrase == confirmPassphrase, ) } Spacer(modifier = Modifier.height(SPACING_DP.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - OutlinedTextField( - value = boots.toString(), - onValueChange = { str -> str.toIntOrNull()?.let { boots = it.coerceIn(1, MAX_BYTE_VALUE) } }, - label = { Text(stringResource(Res.string.lockdown_boots_remaining)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - ) - Spacer(modifier = Modifier.width(SPACING_DP.dp)) - OutlinedTextField( - value = hours.toString(), - onValueChange = { str -> str.toIntOrNull()?.let { hours = it.coerceAtLeast(0) } }, - label = { Text(stringResource(Res.string.lockdown_hours_until_expiry)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - ) - } - Spacer(modifier = Modifier.height(SPACING_DP.dp)) - OutlinedTextField( - value = sessionMinutes.toString(), - onValueChange = { str -> str.toIntOrNull()?.let { sessionMinutes = it.coerceAtLeast(0) } }, - label = { Text(stringResource(Res.string.lockdown_session_minutes)) }, - supportingText = { Text(stringResource(Res.string.lockdown_session_minutes_help)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), + LockdownLimitsFields( + boots = boots, + onBootsChange = { boots = it }, + hours = hours, + onHoursChange = { hours = it }, + sessionMinutes = sessionMinutes, + onSessionMinutesChange = { sessionMinutes = it }, ) } }, @@ -234,7 +152,4 @@ fun LockdownDialog( ) } -// Firmware maximum: AdminMessage.lockdown_auth.passphrase is limited to 64 bytes. -private const val MAX_PASSPHRASE_LEN = 64 -private const val MAX_BYTE_VALUE = 255 private const val SPACING_DP = 8 diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownFields.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownFields.kt new file mode 100644 index 0000000000..2b3ccc5af1 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownFields.kt @@ -0,0 +1,164 @@ +/* + * 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.lockdown + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.lockdown_boots_remaining +import org.meshtastic.core.resources.lockdown_confirm_passphrase +import org.meshtastic.core.resources.lockdown_hide_passphrase +import org.meshtastic.core.resources.lockdown_hours_until_expiry +import org.meshtastic.core.resources.lockdown_passphrases_do_not_match +import org.meshtastic.core.resources.lockdown_session_minutes +import org.meshtastic.core.resources.lockdown_session_minutes_help +import org.meshtastic.core.resources.lockdown_show_passphrase +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Visibility +import org.meshtastic.core.ui.icon.VisibilityOff + +// Firmware maximum: AdminMessage.lockdown_auth.passphrase is limited to 64 bytes. +private const val MAX_PASSPHRASE_LEN = 64 +private const val MAX_BYTE_VALUE = 255 +private const val SPACING_DP = 8 + +/** A passphrase the firmware accepts: non-empty and within its byte limit. */ +internal fun isValidLockdownPassphrase(passphrase: String): Boolean = + passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN + +private fun fitsPassphraseLimit(text: String): Boolean = text.encodeToByteArray().size <= MAX_PASSPHRASE_LEN + +@Composable +internal fun LockdownPassphraseField( + value: String, + onValueChange: (String) -> Unit, + label: String, + passwordVisible: Boolean, + onToggleVisibility: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedTextField( + value = value, + onValueChange = { if (fitsPassphraseLimit(it)) onValueChange(it) }, + label = { Text(label) }, + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = onToggleVisibility) { + Icon( + imageVector = if (passwordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, + contentDescription = + stringResource( + if (passwordVisible) { + Res.string.lockdown_hide_passphrase + } else { + Res.string.lockdown_show_passphrase + }, + ), + ) + } + }, + modifier = modifier.fillMaxWidth(), + ) +} + +/** The confirm-passphrase field; flags a mismatch once anything has been typed. */ +@Composable +internal fun LockdownConfirmPassphraseField( + value: String, + onValueChange: (String) -> Unit, + matches: Boolean, + modifier: Modifier = Modifier, +) { + val showMismatch = value.isNotEmpty() && !matches + OutlinedTextField( + value = value, + onValueChange = { if (fitsPassphraseLimit(it)) onValueChange(it) }, + label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + isError = showMismatch, + supportingText = + if (showMismatch) { + { Text(stringResource(Res.string.lockdown_passphrases_do_not_match)) } + } else { + null + }, + modifier = modifier.fillMaxWidth(), + ) +} + +/** Boots, hours and session-minute limits for a new lockdown passphrase, clamped to what firmware stores. */ +@Composable +internal fun LockdownLimitsFields( + boots: Int, + onBootsChange: (Int) -> Unit, + hours: Int, + onHoursChange: (Int) -> Unit, + sessionMinutes: Int, + onSessionMinutesChange: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedTextField( + value = boots.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { onBootsChange(it.coerceIn(1, MAX_BYTE_VALUE)) } }, + label = { Text(stringResource(Res.string.lockdown_boots_remaining)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(SPACING_DP.dp)) + OutlinedTextField( + value = hours.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { onHoursChange(it.coerceAtLeast(0)) } }, + label = { Text(stringResource(Res.string.lockdown_hours_until_expiry)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(SPACING_DP.dp)) + OutlinedTextField( + value = sessionMinutes.toString(), + onValueChange = { str -> str.toIntOrNull()?.let { onSessionMinutesChange(it.coerceAtLeast(0)) } }, + label = { Text(stringResource(Res.string.lockdown_session_minutes)) }, + supportingText = { Text(stringResource(Res.string.lockdown_session_minutes_help)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt index 5ad2b28bb8..f5a7c2aad7 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownModeSetting.kt @@ -16,22 +16,15 @@ */ package org.meshtastic.feature.settings.lockdown -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -43,9 +36,6 @@ 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.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.service.LockdownState @@ -53,15 +43,11 @@ import org.meshtastic.core.model.service.LockdownTokenInfo import org.meshtastic.core.repository.LockdownPassphraseStore import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.lockdown_boots_remaining -import org.meshtastic.core.resources.lockdown_confirm_passphrase import org.meshtastic.core.resources.lockdown_disable import org.meshtastic.core.resources.lockdown_disable_message import org.meshtastic.core.resources.lockdown_enable import org.meshtastic.core.resources.lockdown_enable_ack import org.meshtastic.core.resources.lockdown_enable_warning -import org.meshtastic.core.resources.lockdown_hide_passphrase -import org.meshtastic.core.resources.lockdown_hours_until_expiry import org.meshtastic.core.resources.lockdown_lock_now import org.meshtastic.core.resources.lockdown_mode import org.meshtastic.core.resources.lockdown_mode_setting_up @@ -69,15 +55,8 @@ import org.meshtastic.core.resources.lockdown_mode_summary_locked import org.meshtastic.core.resources.lockdown_mode_summary_off import org.meshtastic.core.resources.lockdown_mode_summary_unlocked import org.meshtastic.core.resources.lockdown_passphrase -import org.meshtastic.core.resources.lockdown_passphrases_do_not_match -import org.meshtastic.core.resources.lockdown_session_minutes -import org.meshtastic.core.resources.lockdown_session_minutes_help import org.meshtastic.core.resources.lockdown_set_passphrase -import org.meshtastic.core.resources.lockdown_show_passphrase import org.meshtastic.core.ui.component.SwitchPreference -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Visibility -import org.meshtastic.core.ui.icon.VisibilityOff import org.meshtastic.feature.settings.radio.component.NodeActionButton /** @@ -169,7 +148,6 @@ fun ColumnScope.LockdownModeSetting( } } -@Suppress("LongMethod") @Composable private fun EnableLockdownDialog( onConfirm: (passphrase: String, boots: Int, hours: Int, sessionMinutes: Int) -> Unit, @@ -183,9 +161,8 @@ private fun EnableLockdownDialog( var sessionMinutes by rememberSaveable { mutableIntStateOf(0) } var acknowledged by rememberSaveable { mutableStateOf(false) } - val passphraseValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN val matches = passphrase == confirmPassphrase - val isValid = passphraseValid && matches && acknowledged + val isValid = isValidLockdownPassphrase(passphrase) && matches && acknowledged AlertDialog( onDismissRequest = onDismiss, @@ -197,7 +174,7 @@ private fun EnableLockdownDialog( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(SPACING_DP.dp)) - PassphraseField( + LockdownPassphraseField( value = passphrase, onValueChange = { passphrase = it }, label = stringResource(Res.string.lockdown_passphrase), @@ -205,50 +182,19 @@ private fun EnableLockdownDialog( onToggleVisibility = { passwordVisible = !passwordVisible }, ) Spacer(modifier = Modifier.height(SPACING_DP.dp)) - OutlinedTextField( + LockdownConfirmPassphraseField( value = confirmPassphrase, - onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) confirmPassphrase = it }, - label = { Text(stringResource(Res.string.lockdown_confirm_passphrase)) }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - isError = confirmPassphrase.isNotEmpty() && !matches, - supportingText = - if (confirmPassphrase.isNotEmpty() && !matches) { - { Text(stringResource(Res.string.lockdown_passphrases_do_not_match)) } - } else { - null - }, - modifier = Modifier.fillMaxWidth(), + onValueChange = { confirmPassphrase = it }, + matches = matches, ) Spacer(modifier = Modifier.height(SPACING_DP.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - OutlinedTextField( - value = boots.toString(), - onValueChange = { str -> str.toIntOrNull()?.let { boots = it.coerceIn(1, MAX_BYTE_VALUE) } }, - label = { Text(stringResource(Res.string.lockdown_boots_remaining)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - ) - Spacer(modifier = Modifier.width(SPACING_DP.dp)) - OutlinedTextField( - value = hours.toString(), - onValueChange = { str -> str.toIntOrNull()?.let { hours = it.coerceAtLeast(0) } }, - label = { Text(stringResource(Res.string.lockdown_hours_until_expiry)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - ) - } - Spacer(modifier = Modifier.height(SPACING_DP.dp)) - OutlinedTextField( - value = sessionMinutes.toString(), - onValueChange = { str -> str.toIntOrNull()?.let { sessionMinutes = it.coerceAtLeast(0) } }, - label = { Text(stringResource(Res.string.lockdown_session_minutes)) }, - supportingText = { Text(stringResource(Res.string.lockdown_session_minutes_help)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), + LockdownLimitsFields( + boots = boots, + onBootsChange = { boots = it }, + hours = hours, + onHoursChange = { hours = it }, + sessionMinutes = sessionMinutes, + onSessionMinutesChange = { sessionMinutes = it }, ) Spacer(modifier = Modifier.height(SPACING_DP.dp)) Row(verticalAlignment = Alignment.CenterVertically) { @@ -270,7 +216,7 @@ private fun EnableLockdownDialog( private fun DisableLockdownDialog(onConfirm: (passphrase: String) -> Unit, onDismiss: () -> Unit) { var passphrase by rememberSaveable { mutableStateOf("") } var passwordVisible by rememberSaveable { mutableStateOf(false) } - val isValid = passphrase.isNotEmpty() && passphrase.encodeToByteArray().size <= MAX_PASSPHRASE_LEN + val isValid = isValidLockdownPassphrase(passphrase) AlertDialog( onDismissRequest = onDismiss, @@ -279,7 +225,7 @@ private fun DisableLockdownDialog(onConfirm: (passphrase: String) -> Unit, onDis Column { Text(stringResource(Res.string.lockdown_disable_message)) Spacer(modifier = Modifier.height(SPACING_DP.dp)) - PassphraseField( + LockdownPassphraseField( value = passphrase, onValueChange = { passphrase = it }, label = stringResource(Res.string.lockdown_passphrase), @@ -297,40 +243,4 @@ private fun DisableLockdownDialog(onConfirm: (passphrase: String) -> Unit, onDis ) } -@Composable -private fun PassphraseField( - value: String, - onValueChange: (String) -> Unit, - label: String, - passwordVisible: Boolean, - onToggleVisibility: () -> Unit, -) { - OutlinedTextField( - value = value, - onValueChange = { if (it.encodeToByteArray().size <= MAX_PASSPHRASE_LEN) onValueChange(it) }, - label = { Text(label) }, - singleLine = true, - visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = onToggleVisibility) { - Icon( - imageVector = if (passwordVisible) MeshtasticIcons.VisibilityOff else MeshtasticIcons.Visibility, - contentDescription = - stringResource( - if (passwordVisible) { - Res.string.lockdown_hide_passphrase - } else { - Res.string.lockdown_show_passphrase - }, - ), - ) - } - }, - modifier = Modifier.fillMaxWidth(), - ) -} - -// Firmware maximum: AdminMessage.lockdown_auth.passphrase is limited to 64 bytes. -private const val MAX_PASSPHRASE_LEN = 64 -private const val MAX_BYTE_VALUE = 255 private const val SPACING_DP = 8 diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt index 30af59d0b0..6c10f654e0 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/lockdown/LockdownSessionStatus.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.model.service.LockdownTokenInfo +import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.lockdown_session_boots_remaining import org.meshtastic.core.resources.lockdown_session_expires @@ -50,7 +51,7 @@ fun LockdownSessionStatus(tokenInfo: LockdownTokenInfo?, modifier: Modifier = Mo text = stringResource( Res.string.lockdown_session_expires, - DateFormatter.formatDateTime(tokenInfo.expiryEpoch * MILLIS_PER_SECOND), + DateFormatter.formatDateTime(tokenInfo.expiryEpoch * TimeConstants.MS_PER_SEC), ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -67,4 +68,3 @@ fun LockdownSessionStatus(tokenInfo: LockdownTokenInfo?, modifier: Modifier = Mo private const val PADDING_DP = 8 private const val PADDING_VERTICAL_DP = 4 -private const val MILLIS_PER_SECOND = 1000L diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt index 00b1d88c1c..6c5861b7b8 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt @@ -35,6 +35,7 @@ import okio.ByteString import okio.ByteString.Companion.toByteString import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Capabilities +import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.model.util.encodeToString import org.meshtastic.core.model.util.platformRandomBytes import org.meshtastic.core.resources.Res @@ -225,7 +226,7 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un passphrase = passphrase, boots = boots, hours = hours, - maxSessionSeconds = sessionMinutes * SECONDS_PER_MINUTE, + maxSessionSeconds = sessionMinutes * TimeConstants.SECONDS_PER_MINUTE, ) }, onDisable = { passphrase -> @@ -359,5 +360,3 @@ fun PrivateKeyRegenerateDialog( /** X25519 private key length in bytes. */ private const val PRIVATE_KEY_SIZE = 32 - -private const val SECONDS_PER_MINUTE = 60 diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt index 14412218d6..ef5c81ded5 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt @@ -17,12 +17,17 @@ package org.meshtastic.feature.settings.tak import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import org.meshtastic.core.ui.util.rememberFileExporter /** - * Platform-specific composable that returns a launcher for exporting a TAK data package zip. + * Returns a launcher for exporting a TAK data package zip. * * @param dataPackageProvider suspend function producing the zip [ByteArray] * @return a lambda accepting the suggested file name to trigger the export */ @Composable -expect fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit +fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { + val export = rememberFileExporter(content = dataPackageProvider) + return remember(export) { { fileName -> export(fileName, "application/zip") } } +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/lockdown/LockdownPassphraseValidationTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/lockdown/LockdownPassphraseValidationTest.kt new file mode 100644 index 0000000000..0eb9a6560e --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/lockdown/LockdownPassphraseValidationTest.kt @@ -0,0 +1,38 @@ +/* + * 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.lockdown + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LockdownPassphraseValidationTest { + + @Test + fun `an empty passphrase is rejected`() { + assertFalse(isValidLockdownPassphrase("")) + } + + @Test + fun `the firmware limit is counted in bytes not characters`() { + assertTrue(isValidLockdownPassphrase("a".repeat(64))) + assertFalse(isValidLockdownPassphrase("a".repeat(65))) + // Two bytes each in UTF-8: 32 fit, 33 do not. + assertTrue(isValidLockdownPassphrase("é".repeat(32))) + assertFalse(isValidLockdownPassphrase("é".repeat(33))) + } +} diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt index b47f97d702..820a0ad6ce 100644 --- a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt +++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt @@ -16,8 +16,4 @@ */ package org.meshtastic.feature.settings.debugging -import androidx.compose.runtime.Composable - -@Composable actual fun rememberLogExporter(contentProvider: suspend () -> String): (fileName: String) -> Unit = { _ -> } - actual fun captureAppLogcat(): String = "" diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 991afe4929..92ed8db30c 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -25,17 +25,13 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold 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.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.common.util.UnitsOverride @@ -46,22 +42,17 @@ import org.meshtastic.core.navigation.WifiProvisionRoute import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.about 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.device_links import org.meshtastic.core.resources.discovery_local_mesh import org.meshtastic.core.resources.help_and_documentation import org.meshtastic.core.resources.info -import org.meshtastic.core.resources.modules_already_unlocked -import org.meshtastic.core.resources.modules_unlocked import org.meshtastic.core.resources.node_layout_section_title import org.meshtastic.core.resources.preferences_language -import org.meshtastic.core.resources.remotely_administrating import org.meshtastic.core.resources.theme import org.meshtastic.core.resources.units import org.meshtastic.core.resources.wifi_devices 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.icon.ChevronRight import org.meshtastic.core.ui.icon.Device @@ -71,16 +62,16 @@ import org.meshtastic.core.ui.icon.HelpOutline import org.meshtastic.core.ui.icon.Info import org.meshtastic.core.ui.icon.Language import org.meshtastic.core.ui.icon.List -import org.meshtastic.core.ui.icon.Memory import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.PermScanWifi import org.meshtastic.core.ui.icon.Wifi -import org.meshtastic.core.ui.util.rememberShowToastResource +import org.meshtastic.feature.settings.component.AppVersionButton import org.meshtastic.feature.settings.component.CacheLimitPreference import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.component.FullMessageTimestampsSetting import org.meshtastic.feature.settings.component.HomoglyphSetting import org.meshtastic.feature.settings.component.NotificationSection +import org.meshtastic.feature.settings.component.RadioAdminAppBar import org.meshtastic.feature.settings.component.ThemePickerDialog import org.meshtastic.feature.settings.component.UnitsOption import org.meshtastic.feature.settings.component.UnitsPickerDialog @@ -90,7 +81,6 @@ import org.meshtastic.feature.settings.radio.RadioConfigItemList import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.search.SettingsSearchBar import org.meshtastic.feature.settings.search.SettingsSearchViewModel -import kotlin.time.Duration.Companion.seconds /** * Desktop-specific top-level settings screen. Replaces the Android `SettingsScreen` which uses Android-specific APIs @@ -142,21 +132,13 @@ fun DesktopSettingsScreen( Scaffold( topBar = { - MainAppBar( + RadioAdminAppBar( title = stringResource(Res.string.bottom_nav_settings), - subtitle = - if (state.isLocal) { - null - } else { - val remoteName = destNode?.user?.long_name ?: "" - stringResource(Res.string.remotely_administrating, remoteName) - }, - ourNode = null, - showNodeChip = false, - canNavigateUp = false, + isLocal = state.isLocal, + destNode = destNode, onNavigateUp = {}, - actions = {}, - onClickChip = {}, + localSubtitle = null, + canNavigateUp = false, ) }, ) { paddingValues -> @@ -315,7 +297,7 @@ private fun DesktopAppInfoSection( onNavigateToAbout() } - DesktopAppVersionButton( + AppVersionButton( hiddenFeaturesUnlocked = hiddenFeaturesUnlocked, appVersionName = appVersionName, onUnlockHiddenFeatures = onUnlockHiddenFeatures, @@ -323,50 +305,6 @@ private fun DesktopAppInfoSection( } } -private const val UNLOCK_CLICK_COUNT = 5 -private const val UNLOCKED_CLICK_COUNT = 3 -private const val UNLOCK_TIMEOUT_SECONDS = 1 - -@Composable -private fun DesktopAppVersionButton( - hiddenFeaturesUnlocked: Boolean, - appVersionName: String, - onUnlockHiddenFeatures: () -> Unit, -) { - val scope = rememberCoroutineScope() - val showToast = rememberShowToastResource() - var clickCount by remember { mutableStateOf(0) } - - LaunchedEffect(clickCount) { - if (clickCount in 1.. { - clickCount = 0 - scope.launch { showToast(Res.string.modules_already_unlocked) } - } - - clickCount == UNLOCK_CLICK_COUNT -> { - clickCount = 0 - onUnlockHiddenFeatures() - scope.launch { showToast(Res.string.modules_unlocked) } - } - } - } -} - /** * Supported languages — tag must match the CMP `values-` directory names. Empty tag means system default. * Display names are written in the native language for clarity. diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt index 817536abfa..47107a947c 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt @@ -16,58 +16,7 @@ */ package org.meshtastic.feature.settings.debugging -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.meshtastic.core.common.log.InMemoryLogBuffer -import org.meshtastic.core.common.util.ioDispatcher -import java.awt.FileDialog -import java.awt.Frame -import java.io.File -import java.io.FileOutputStream -import java.io.OutputStreamWriter -import java.nio.charset.StandardCharsets - -@Composable -actual fun rememberLogExporter(contentProvider: suspend () -> String): (fileName: String) -> Unit { - val scope = rememberCoroutineScope() - - return { fileName -> - scope.launch { - val content = contentProvider() - if (content.isBlank()) { - Logger.w { "Log export aborted: no content" } - return@launch - } - - withContext(ioDispatcher) { - // Run file dialog to ask user where to save - val fileDialog = FileDialog(null as Frame?, "Export Logs", FileDialog.SAVE) - fileDialog.file = fileName - fileDialog.isVisible = true - - val directory = fileDialog.directory - val selectedFile = fileDialog.file - - if (directory != null && selectedFile != null) { - val exportFile = File(directory, selectedFile) - try { - FileOutputStream(exportFile).use { fos -> - OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer -> writer.write(content) } - } - Logger.i { "Logs exported successfully to ${exportFile.absolutePath}" } - } catch (e: java.io.IOException) { - Logger.e(e) { "Failed to export logs to file: ${exportFile.absolutePath}" } - } - } else { - Logger.w { "Log export aborted: user canceled file dialog" } - } - } - } - } -} // Desktop has no system logcat; surface the app's own Kermit output captured by InMemoryLogBuffer (installed at // startup). diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt deleted file mode 100644 index bfbb85bc0d..0000000000 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt +++ /dev/null @@ -1,54 +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 org.meshtastic.feature.settings.tak - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.meshtastic.core.common.util.ioDispatcher -import java.awt.FileDialog -import java.awt.Frame -import java.io.File - -@Composable -actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit { - val scope = rememberCoroutineScope() - return { fileName -> - scope.launch { - runCatching { - val fileDialog = - FileDialog(null as Frame?, "Export TAK Data Package", FileDialog.SAVE).apply { - file = fileName - isVisible = true - } - - val directory = fileDialog.directory - val file = fileDialog.file - - if (directory != null && file != null) { - val targetFile = File(directory, file) - val data = dataPackageProvider() - withContext(ioDispatcher) { targetFile.writeBytes(data) } - Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" } - } - } - .onFailure { e -> Logger.e(e) { "Failed to export TAK data package" } } - } - } -} diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt index 1b22675ed0..c592fdc504 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt @@ -68,7 +68,6 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject 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 import org.meshtastic.core.resources.battery @@ -76,6 +75,7 @@ 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.formatDuration import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.local_stats_bad import org.meshtastic.core.resources.local_stats_diagnostics_prefix @@ -368,7 +368,7 @@ class LocalStatsWidget : style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp), ) Text( - text = formatUptime(state.uptimeSecs.toInt()), + text = formatDuration(state.uptimeSecs), maxLines = 1, style = TextStyle(