mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-02 16:44:33 -04:00
refactor: keep one copy of shared code across platforms (#7432)
This commit is contained in:
1 parent
203634804f
commit
e6cf616bdb
117 files changed
+2508
-2854
No files matched your search
Generated
+6
-1
@@ -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(
|
||||
|
||||
+8
-31
@@ -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
|
||||
+2
-26
@@ -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
|
||||
}
|
||||
}
|
||||
-204
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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
|
||||
|
||||
+59
@@ -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
-51
@@ -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())
|
||||
|
||||
+33
-64
@@ -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),
|
||||
|
||||
+37
-78
@@ -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())
|
||||
}
|
||||
|
||||
+33
-69
@@ -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")
|
||||
}
|
||||
}
|
||||
+48
@@ -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) }
|
||||
}
|
||||
}
|
||||
+37
-89
@@ -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
-51
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
+3
-27
@@ -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")
|
||||
|
||||
|
||||
+23
-22
@@ -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 +
|
||||
|
||||
+2
-25
@@ -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? {
|
||||
|
||||
+28
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-77
@@ -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
|
||||
+5
-8
@@ -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()
|
||||
}
|
||||
+15
-4
@@ -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() {
|
||||
|
||||
+3
-2
@@ -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() },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
+9
-72
@@ -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
|
||||
}
|
||||
|
||||
+6
-6
@@ -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
|
||||
}
|
||||
+10
-67
@@ -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()) }
|
||||
}
|
||||
+22
-8
@@ -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())
|
||||
}
|
||||
}
|
||||
+73
@@ -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())
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
-65
@@ -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)
|
||||
}
|
||||
}
|
||||
+11
-4
@@ -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
-7
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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))
|
||||
|
||||
+3
-8
@@ -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"
|
||||
}
|
||||
+3
-3
@@ -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)
|
||||
|
||||
|
||||
+3
-2
@@ -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,
|
||||
)
|
||||
|
||||
+47
@@ -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)
|
||||
}
|
||||
}
|
||||
-53
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-184
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -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)
|
||||
|
||||
+2
-3
@@ -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
|
||||
}
|
||||
}
|
||||
+7
@@ -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"))
|
||||
|
||||
+166
@@ -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
|
||||
}
|
||||
+137
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-149
@@ -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()
|
||||
}
|
||||
+85
@@ -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)
|
||||
}
|
||||
}
|
||||
+159
@@ -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()
|
||||
|
||||
+7
-22
@@ -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)
|
||||
|
||||
+42
@@ -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)
|
||||
}
|
||||
+10
-8
@@ -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
|
||||
|
||||
+45
@@ -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))
|
||||
}
|
||||
}
|
||||
+17
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
+13
-45
@@ -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()
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-14
@@ -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
|
||||
|
||||
-42
@@ -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,
|
||||
)
|
||||
}
|
||||
+6
-2
@@ -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
|
||||
|
||||
-43
@@ -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,
|
||||
)
|
||||
}
|
||||
-48
@@ -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()
|
||||
}
|
||||
}
|
||||
-45
@@ -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,
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -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),
|
||||
)
|
||||
|
||||
-200
@@ -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,
|
||||
)
|
||||
}
|
||||
+6
-2
@@ -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,
|
||||
)
|
||||
|
||||
+2
-2
@@ -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))
|
||||
|
||||
+2
-20
@@ -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,
|
||||
)
|
||||
|
||||
+6
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+7
-13
@@ -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 ->
|
||||
|
||||
-58
@@ -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() {
|
||||
|
||||
-54
@@ -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
|
||||
|
||||
-51
@@ -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" }
|
||||
}
|
||||
}
|
||||
+4
-15
@@ -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 ->
|
||||
|
||||
+4
-15
@@ -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 ->
|
||||
|
||||
+4
-15
@@ -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
Reference in new issue
Block a user