refactor: keep one copy of shared code across platforms (#7432)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-29 15:56:05 +00:00
1 parent 203634804f
commit e6cf616bdb
117 files changed
+2508 -2854

No files matched your search

+6 -1
View File
@@ -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
@@ -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<Node?> =
sitePlannerRequestState.request.stateInWhileSubscribed(initialValue = null)
fun setSitePlannerNodeNum(nodeNum: Int?) {
sitePlannerRequestState.setNodeNum(nodeNum)
}
fun consumeSitePlannerRequest(nodeNum: Int) {
sitePlannerRequestState.consume(nodeNum)
}
}
internal sealed interface InitialCameraState {
@@ -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<Int>("waypointId"))
val selectedWaypointId: StateFlow<Int?> = _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<Node?> =
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(
@@ -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
@@ -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
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<PacketRepository>(MockMode.autofill)
private val mapPrefs = FakeMapPrefs()
private val googleMapsPrefs = mock<GoogleMapsPrefs>(MockMode.autofill)
private val customTileProviderRepository = mock<CustomTileProviderRepository>(MockMode.autofill)
private val networkRepository = mock<NetworkRepository>(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<GoogleCameraPosition?>(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<List<CustomTileProviderConfig>>(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()
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
@@ -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
@@ -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<NetworkDeviceHardware> = 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())
@@ -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<NetworkDeviceHardware>) : ApiService {
var hardwareCalls = 0
var responseGate: CompletableDeferred<Unit>? = null
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> {
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<NetworkDeviceHardware>, 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<NetworkDeviceHardware>, 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<Unit>()
val gate = CompletableDeferred<Unit>()
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),
@@ -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<NetworkDeviceHardware> = 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<NetworkDeviceLink>, 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<NetworkDeviceLink>) =
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())
}
@@ -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<NetworkDeviceHardware> = 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<EventFirmwareEdition>,
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<EventFirmwareEdition>) =
seed.put(EVENT_ASSET, EventFirmwareResponse(editions = editions), json)
private fun serveEditions(editions: List<EventFirmwareEdition>) {
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"
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<NetworkDeviceHardware> = { 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<NetworkDeviceHardware> {
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")
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String, String>()
/** 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 <reified T> 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) }
}
}
@@ -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<Unit>? = null
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> = 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<Unit>()
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<Unit>()
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()
@@ -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<NetworkDeviceHardware> = 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())
@@ -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<Pair<Long, Double>>): List<Int?> {
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.
@@ -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.
*
@@ -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
}
@@ -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
}
+2
View File
@@ -28,6 +28,8 @@ kotlin {
}
withHostTest { isIncludeAndroidResources = true }
}
sourceSets { commonMain.dependencies { implementation(projects.core.common) } }
}
compose.resources {
@@ -197,7 +197,6 @@
<string name="channel_8">Channel 8</string>
<string name="channel_features">Channel Features</string>
<string name="channel_invalid">This Channel URL is invalid and can not be used</string>
<string name="channel_label">Channel</string>
<string name="channel_name">Channel Name</string>
<string name="channel_url">Channel URL</string>
<string name="channel_utilization">ChUtil</string>
@@ -551,6 +550,12 @@
<string name="download">Download</string>
<string name="download_this_area">Download this area</string>
<string name="duplicated_public_key_title">Duplicate Public Key Detected</string>
<!-- DURATION -->
<string name="duration_ago">%1$s ago</string>
<string name="duration_days_short">%1$dd</string>
<string name="duration_hours_short">%1$dh</string>
<string name="duration_minutes_short">%1$dm</string>
<string name="duration_seconds_short">%1$ds</string>
<string name="dynamic">Dynamic</string>
<string name="easily_set_up_private_mesh_networks">Easily set up private mesh networks for secure and reliable communication in remote areas.</string>
<string name="edit">Edit</string>
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
@@ -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")
@@ -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<String>()
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 +
@@ -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? {
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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<PacketRepository>,
dataHandler: Lazy<MeshDataHandler>,
analytics: PlatformAnalytics,
meshPrefs: MeshPrefs,
uiPrefs: UiPrefs,
databaseManager: DatabaseManager,
serviceNotifications: MeshNotificationManager,
messageProcessor: Lazy<MeshMessageProcessor>,
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
@@ -14,12 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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()
}
@@ -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<MeshMessageProcessor>,
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() {
@@ -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() },
)
}
@@ -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 = {},
)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
@@ -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)
}
}
@@ -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
}
@@ -14,12 +14,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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
}
@@ -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
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()) }
}
@@ -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<IllegalStateException> { 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())
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<IllegalStateException> { reopened.save(7, "pub7", "priv7", timestamp = 2L) }
assertContentEquals(keystoreBefore, File(storeDir, "keystore.p12").readBytes())
assertContentEquals(entryBefore, File(storeDir, "42.enc").readBytes())
}
}
@@ -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))
@@ -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,
@@ -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 <https://www.gnu.org/licenses/>.
*/
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) }
}
@@ -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
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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<PacketRepository>,
dataHandler: Lazy<MeshDataHandler>,
analytics: PlatformAnalytics,
meshPrefs: MeshPrefs,
uiPrefs: UiPrefs,
databaseManager: DatabaseManager,
serviceNotifications: MeshNotificationManager,
messageProcessor: Lazy<MeshMessageProcessor>,
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.
+1 -1
View File
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ExportResult.Success?>(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)
}
}
@@ -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<Pair<String, String>> = 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)}")
@@ -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<ExportResult.Success?>(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 {
@@ -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))
@@ -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"
}
@@ -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)
@@ -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,
)
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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" }
}
}
}
}
}
+4 -1
View File
@@ -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)
}
}
}
@@ -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<Pair<ZipEntry, File>>()
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<Pair<ZipEntry, File>>()
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
}
}
}
@@ -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)
@@ -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
}
}
@@ -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"))
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
@@ -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<String, File>? {
val candidates = mutableListOf<Pair<String, File>>()
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)
}
}
@@ -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<String, ByteArray> =
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<Pair<ZipEntry, File>>()
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()
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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<IllegalArgumentException> {
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<IllegalArgumentException> {
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<IllegalArgumentException> {
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<IllegalArgumentException> {
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<IllegalArgumentException> {
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<IOException> {
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()
@@ -56,24 +56,14 @@ class TerrainRegionExtractor(
}
fun download(bounds: GeoBounds, maxZoom: Int): Flow<TerrainDownloadState> = 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)
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
@@ -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)
@@ -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<Node?> =
sitePlannerRequestState.request.stateInWhileSubscribed(initialValue = null)
fun setSitePlannerNodeNum(nodeNum: Int?) {
sitePlannerRequestState.setNodeNum(nodeNum)
}
fun consumeSitePlannerRequest(nodeNum: Int) {
sitePlannerRequestState.consume(nodeNum)
}
val waypoints: StateFlow<Map<Int, DataPacket>> =
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)
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map
package org.meshtastic.feature.map
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -14,15 +14,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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<PacketRepository>(MockMode.autofill)
private val networkRepository = mock<NetworkRepository>(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()
}
@@ -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<CompassWarning> = emptyList(),
val errorRadiusText: String? = null,
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
@@ -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),
)
@@ -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 <https://www.gnu.org/licenses/>.
*/
@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,
)
}
@@ -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,
)
@@ -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))
@@ -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,
)
@@ -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,
)
}
@@ -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 ->
@@ -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..<UNLOCK_CLICK_COUNT) {
delay(UNLOCK_TIMEOUT_SECONDS.seconds)
clickCount = 0
}
}
ListItem(
text = stringResource(Res.string.app_version),
leadingIcon = MeshtasticIcons.Memory,
supportingText = appVersionName,
trailingIcon = null,
) {
clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT)
when {
clickCount == UNLOCKED_CLICK_COUNT && hiddenFeaturesUnlocked -> {
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() {
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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" }
}
}
@@ -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 ->
@@ -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 ->
@@ -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 ->
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more