mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-02 16:44:33 -04:00
fix: audit leftovers (neighbor-info interval unit, shared constants) (#7481)
This commit is contained in:
1 parent
017de61ad1
commit
364fd9d9dd
18 files changed
+212
-58
No files matched your search
@@ -139,7 +139,7 @@ The tiers are named here and the workflows carry the label versions.
|
||||
- `org.gradle.isolated-projects=true` for better parallelism
|
||||
|
||||
### CI Conventions
|
||||
- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that depends on `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` for every KMP module in the hand-maintained `ALL_MODULES_FULL` list, plus `compileAndroidDeviceTest` for `:core:database` and `:core:model`. `scripts/check-module-list.py` fails the PR when that list drifts from `settings.gradle.kts`. CI runs it in `shard-core`.
|
||||
- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that depends on `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` for every KMP module in the hand-maintained `ALL_MODULES_FULL` list, plus `assembleAndroidDeviceTest` for `:core:database` and `:core:model`, so a device-test APK that fails to dex or package fails here. `scripts/check-module-list.py` fails the PR when that list drifts from `settings.gradle.kts`. CI runs it in `shard-core`.
|
||||
- **Kotlin warnings fail the test shards:** they pass `-PwarningsAsErrors=true`, which sets `allWarningsAsErrors` on every Kotlin compilation (`KotlinAndroid.kt`, plus `desktopApp` and `schema-strings`). The shards don't run the `compile*MainKotlinMetadata` tasks, so a warning only those report doesn't fail CI (today they warn about duplicate KLIB names). Reproduce locally with the same flag on the compile or test tasks you touched.
|
||||
- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` reads the `ci` Gradle property (`providers.gradleProperty("ci")`) and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`.
|
||||
- **Detekt report formats:** Detekt.kt checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations.
|
||||
|
||||
+2
-2
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import java.io.IOException
|
||||
import java.util.zip.GZIPInputStream
|
||||
import kotlin.uuid.Uuid
|
||||
@@ -121,7 +122,7 @@ internal class OfflineRegionExtractor(private val store: OfflineRegionStore) {
|
||||
maxZoom = zoomRange.last,
|
||||
tileCount = tiles.size.toLong(),
|
||||
byteSize = archiveFile.length(),
|
||||
createdAtEpochSeconds = System.currentTimeMillis() / MILLIS_PER_SECOND,
|
||||
createdAtEpochSeconds = nowSeconds,
|
||||
)
|
||||
.also { store.add(it) }
|
||||
} catch (e: IOException) {
|
||||
@@ -171,7 +172,6 @@ internal class OfflineRegionExtractor(private val store: OfflineRegionStore) {
|
||||
const val MAX_REGIONS = 10
|
||||
const val MAX_TOTAL_BYTES = 300L * 1024 * 1024
|
||||
private const val PROGRESS_STRIDE = 10
|
||||
private const val MILLIS_PER_SECOND = 1_000L
|
||||
|
||||
/** Both the Protomaps build and the MVT layers it packages (OpenStreetMap) require attribution. */
|
||||
const val ATTRIBUTION = "© OpenStreetMap contributors, © Protomaps"
|
||||
|
||||
@@ -68,13 +68,13 @@ private fun Project.registerKmpSmokeCompileTask() {
|
||||
dependsOn("$path:compileKotlinIosSimulatorArm64")
|
||||
}
|
||||
|
||||
// Compile androidDeviceTest sources so instrumented test breakages are caught early.
|
||||
// These tests require a device/emulator to *run*, but compilation alone is cheap.
|
||||
DEVICE_TEST_MODULES.forEach { path -> dependsOn("$path:compileAndroidDeviceTest") }
|
||||
// Assemble, not just compile, the androidDeviceTest APKs: dexing and packaging failures only show up there.
|
||||
// Running them still needs a device.
|
||||
DEVICE_TEST_MODULES.forEach { path -> dependsOn("$path:assembleAndroidDeviceTest") }
|
||||
}
|
||||
}
|
||||
|
||||
/** KMP modules that declare `withDeviceTest {}` and therefore have `compileAndroidDeviceTest` tasks. */
|
||||
/** KMP modules that declare `withDeviceTest {}` and therefore have `assembleAndroidDeviceTest` tasks. */
|
||||
private val DEVICE_TEST_MODULES = listOf(":core:database", ":core:model")
|
||||
|
||||
/**
|
||||
|
||||
@@ -154,7 +154,7 @@ class KableBleConnection(private val scope: CoroutineScope, private val loggingC
|
||||
// _deviceFlow.emit() is intentionally outside this block — making it
|
||||
// non-cancellable could hang teardown on a slow collector.
|
||||
withContext(NonCancellable) {
|
||||
cleanUpPeripheral(tag = device.address.anonymize())
|
||||
cleanUpPeripheral(device)
|
||||
peripheral = p
|
||||
ActiveBleConnection.active = ActiveConnection(p, device.address)
|
||||
}
|
||||
@@ -312,8 +312,8 @@ class KableBleConnection(private val scope: CoroutineScope, private val loggingC
|
||||
override fun invalidateServiceCache(): Boolean = peripheral?.refreshGattCache() == true
|
||||
|
||||
/** Ensures the previous peripheral's GATT resources are fully released. */
|
||||
private suspend fun cleanUpPeripheral(tag: String) {
|
||||
withContext(NonCancellable) { safeClosePeripheral(tag) }
|
||||
private suspend fun cleanUpPeripheral(device: BleDevice) {
|
||||
withContext(NonCancellable) { safeClosePeripheral(device.address.anonymize()) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-2
@@ -66,7 +66,6 @@ import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
|
||||
@Suppress("TooManyFunctions", "CyclomaticComplexMethod", "LongParameterList")
|
||||
@@ -435,7 +434,7 @@ class CommandSenderImpl(
|
||||
val neighborInfoToSend =
|
||||
neighborInfoHandler.lastNeighborInfo
|
||||
?: run {
|
||||
val oneHour = 1.hours.inWholeMinutes.toInt()
|
||||
val oneHour = TimeConstants.SECONDS_PER_HOUR
|
||||
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
|
||||
NeighborInfo.Builder()
|
||||
.also { wb ->
|
||||
|
||||
@@ -650,8 +650,9 @@ class NodeManagerImpl(
|
||||
updateNodeAndSchedulePersistence(nodeNum, channel, session, transform)
|
||||
}
|
||||
|
||||
override suspend fun updateNodeAndPersist(nodeNum: Int, channel: Int, transform: (Node) -> Node) {
|
||||
val result = updateNodeState(nodeNum, channel, transform)?.next ?: return
|
||||
/** [transform] may run more than once under compare-and-set contention, so it must be side-effect free. */
|
||||
private suspend fun updateNodeAndPersist(nodeNum: Int, transform: (Node) -> Node) {
|
||||
val result = updateNodeState(nodeNum, channel = 0, transform)?.next ?: return
|
||||
if (shouldPersist(result)) persistLatestNode(nodeNum)
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -450,6 +450,19 @@ class CommandSenderImplTest {
|
||||
verify { neighborInfoHandler.recordStartTime(1) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestNeighborInfo_localNode_dummyReportsAnHourBroadcastIntervalInSeconds() = runTest {
|
||||
every { neighborInfoHandler.lastNeighborInfo } returns null
|
||||
val packets = mutableListOf<MeshPacket>()
|
||||
everySuspend { packetHandler.sendToRadio(capture(packets)) } returns true
|
||||
|
||||
commandSender.requestNeighborInfo(requestId = 1, destNum = MY_NODE_NUM)
|
||||
|
||||
val sent = NeighborInfo.ADAPTER.decode(requireNotNull(packets.single().decoded).payload)
|
||||
assertEquals(3600, sent.node_broadcast_interval_secs)
|
||||
assertEquals(listOf(3600), sent.neighbors.map { it.node_broadcast_interval_secs })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestNeighborInfo_remoteNode_sendsRequest() = runTest {
|
||||
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
|
||||
|
||||
-1
@@ -260,7 +260,6 @@ class MeshMessageProcessorImplTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf(myNodeNum, 999), updatedNodes)
|
||||
verifySuspend(mode = VerifyMode.exactly(0)) { nodeManager.updateNodeAndPersist(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+9
-11
@@ -207,7 +207,7 @@ class NodeManagerImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateNodeAndPersist awaits the repository write`() = testScope.runTest {
|
||||
fun `persisting a node update awaits the repository write`() = testScope.runTest {
|
||||
val nodeNum = 1234
|
||||
nodeManager.setNodeDbReady(true)
|
||||
nodeManager.setAllowNodeDbWrites(true)
|
||||
@@ -219,14 +219,14 @@ class NodeManagerImplTest {
|
||||
releaseWrite.await()
|
||||
}
|
||||
|
||||
val update = async { nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 42) } }
|
||||
val update = async { nodeManager.updateNodeStatusAndPersist(nodeNum, "away") }
|
||||
writeStarted.await()
|
||||
assertFalse(update.isCompleted)
|
||||
releaseWrite.complete(Unit)
|
||||
update.await()
|
||||
|
||||
verifySuspend { nodeRepository.upsert(any()) }
|
||||
assertEquals(42, nodeManager.nodeDBbyNodeNum[nodeNum]?.lastHeard)
|
||||
assertEquals("away", nodeManager.nodeDBbyNodeNum[nodeNum]?.nodeStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -254,12 +254,10 @@ class NodeManagerImplTest {
|
||||
persisted += node
|
||||
}
|
||||
|
||||
val first = async { nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 1) } }
|
||||
val first = async { nodeManager.updateNodeStatusAndPersist(nodeNum, "first") }
|
||||
firstWriteStarted.await()
|
||||
val second =
|
||||
async(start = CoroutineStart.UNDISPATCHED) {
|
||||
nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 2) }
|
||||
}
|
||||
async(start = CoroutineStart.UNDISPATCHED) { nodeManager.updateNodeStatusAndPersist(nodeNum, "second") }
|
||||
runCurrent()
|
||||
assertFalse(
|
||||
secondWriteStarted.isCompleted,
|
||||
@@ -270,8 +268,8 @@ class NodeManagerImplTest {
|
||||
first.await()
|
||||
second.await()
|
||||
|
||||
assertEquals(listOf(1, 2), persisted.map(Node::lastHeard))
|
||||
assertEquals(2, nodeManager.nodeDBbyNodeNum[nodeNum]?.lastHeard)
|
||||
assertEquals(listOf("first", "second"), persisted.map(Node::nodeStatus))
|
||||
assertEquals("second", nodeManager.nodeDBbyNodeNum[nodeNum]?.nodeStatus)
|
||||
}
|
||||
|
||||
private fun admitLeases(session: RadioSessionContext) {
|
||||
@@ -387,10 +385,10 @@ class NodeManagerImplTest {
|
||||
nodeManager.setNodeDbReady(true)
|
||||
nodeManager.setAllowNodeDbWrites(false)
|
||||
|
||||
nodeManager.updateNodeAndPersist(nodeNum) { node -> node.copy(lastHeard = 42) }
|
||||
nodeManager.updateNodeStatusAndPersist(nodeNum, "away")
|
||||
|
||||
verifySuspend(exactly(0)) { nodeRepository.upsert(any()) }
|
||||
assertEquals(42, nodeManager.nodeDBbyNodeNum[nodeNum]?.lastHeard)
|
||||
assertEquals("away", nodeManager.nodeDBbyNodeNum[nodeNum]?.nodeStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-3
@@ -40,6 +40,7 @@ import kotlinx.io.IOException
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.util.TimeConstants
|
||||
import org.meshtastic.core.model.util.anonymizePublicHost
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import kotlin.concurrent.Volatile
|
||||
@@ -101,7 +102,6 @@ class TcpTransport(
|
||||
/** TCP connect timeout. A failed connect just feeds the reconnect/backoff loop, so it is not fatal. */
|
||||
const val CONNECT_TIMEOUT_MS = 30_000L
|
||||
private const val READ_BUFFER_SIZE = 1024
|
||||
private const val MILLIS_PER_SECOND = 1_000L
|
||||
|
||||
/**
|
||||
* Minimum session duration for backoff to reset. Sessions shorter than this that ended in peer-EOF are treated
|
||||
@@ -247,14 +247,14 @@ class TcpTransport(
|
||||
retryCount = 1
|
||||
backoff = MIN_BACKOFF_MILLIS
|
||||
} else if (hadData) {
|
||||
val backoffSec = backoff / MILLIS_PER_SECOND
|
||||
val backoffSec = backoff / TimeConstants.MS_PER_SEC
|
||||
Logger.d {
|
||||
"$logTag: [${address.anonymizePublicHost()}] Short session (${sessionUptime}ms); " +
|
||||
"keeping backoff at ${backoffSec}s"
|
||||
}
|
||||
}
|
||||
|
||||
val delaySec = backoff / MILLIS_PER_SECOND
|
||||
val delaySec = backoff / TimeConstants.MS_PER_SEC
|
||||
Logger.i { "$logTag: [${address.anonymizePublicHost()}] Reconnect #$retryCount in ${delaySec}s" }
|
||||
delay(backoff)
|
||||
retryCount++
|
||||
|
||||
@@ -183,16 +183,6 @@ interface NodeManager : NodeIdLookup {
|
||||
/** Session-bound counterpart to [updateNode]; deferred persistence is admitted only for [session]. */
|
||||
fun updateNodeForSession(nodeNum: Int, session: RadioSessionContext, channel: Int = 0, transform: (Node) -> Node)
|
||||
|
||||
/**
|
||||
* Updates a node using a side-effect-free [transform] and awaits any required persistence before returning. The
|
||||
* transform may be evaluated more than once after compare-and-set contention.
|
||||
*
|
||||
* Session-scoped packet processing uses this while holding transport authority so an old session cannot enqueue a
|
||||
* database write that resumes after a device switch. Non-session UI and controller updates continue to use
|
||||
* [updateNode].
|
||||
*/
|
||||
suspend fun updateNodeAndPersist(nodeNum: Int, channel: Int = 0, transform: (Node) -> Node)
|
||||
|
||||
/** Removes a node from the in-memory database by its number. */
|
||||
fun removeByNodenum(nodeNum: Int)
|
||||
|
||||
|
||||
+3
-5
@@ -35,9 +35,9 @@ import org.meshtastic.core.resources.firmware_update_rebooting
|
||||
import org.meshtastic.core.resources.firmware_update_retrieval_failed
|
||||
import org.meshtastic.core.resources.firmware_update_usb_failed
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.feature.firmware.ota.formatTransferPercent
|
||||
|
||||
private const val USB_REBOOT_DELAY = 5000L
|
||||
private const val PERCENT_MAX = 100
|
||||
|
||||
/**
|
||||
* One leg of a multi-pass USB/UF2 sequence.
|
||||
@@ -94,13 +94,12 @@ internal suspend fun performUsbMaintenance(
|
||||
val firmware =
|
||||
try {
|
||||
retrieveUsbFirmware(release, hardware) { progress ->
|
||||
val percent = (progress * PERCENT_MAX).toInt()
|
||||
updateState(
|
||||
FirmwareUpdateState.Downloading(
|
||||
ProgressState(
|
||||
message = UiText.DynamicString(downloadingMsg),
|
||||
progress = progress,
|
||||
details = UiText.DynamicString("$percent%"),
|
||||
details = formatTransferPercent(progress),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -373,13 +372,12 @@ internal suspend fun performUsbUpdate(
|
||||
} else {
|
||||
val firmwareFile =
|
||||
retrieveUsbFirmware(release, hardware) { progress ->
|
||||
val percent = (progress * PERCENT_MAX).toInt()
|
||||
updateState(
|
||||
FirmwareUpdateState.Downloading(
|
||||
ProgressState(
|
||||
message = UiText.DynamicString(downloadingMsg),
|
||||
progress = progress,
|
||||
details = UiText.DynamicString("$percent%"),
|
||||
details = formatTransferPercent(progress),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
+1
-3
@@ -59,7 +59,6 @@ import org.meshtastic.feature.firmware.ProgressState
|
||||
import org.meshtastic.feature.firmware.stripFormatArgs
|
||||
|
||||
private const val RETRY_DELAY = 2000L
|
||||
private const val PERCENT_MAX = 100
|
||||
private const val REBOOT_MODE_BLE = 1
|
||||
private const val REBOOT_MODE_WIFI = 2
|
||||
|
||||
@@ -361,13 +360,12 @@ class Esp32OtaUpdateHandler(
|
||||
|
||||
val firmwareFile =
|
||||
firmwareRetriever.retrieveEsp32Firmware(release, hardware) { progress ->
|
||||
val percent = (progress * PERCENT_MAX).toInt()
|
||||
updateState(
|
||||
FirmwareUpdateState.Downloading(
|
||||
ProgressState(
|
||||
message = UiText.DynamicString(downloadingMsg),
|
||||
progress = progress,
|
||||
details = UiText.DynamicString("$percent%"),
|
||||
details = formatTransferPercent(progress),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
+5
-1
@@ -26,6 +26,10 @@ import org.meshtastic.core.resources.firmware_update_transfer_progress
|
||||
|
||||
private const val PERCENT_MAX = 100
|
||||
|
||||
/** Formats download or transfer progress as a translated bare percentage, e.g. `"42%"` in English. */
|
||||
internal fun formatTransferPercent(progress: Float): UiText =
|
||||
UiText.Resource(Res.string.firmware_update_transfer_percent, (progress * PERCENT_MAX).toInt())
|
||||
|
||||
/**
|
||||
* Formats firmware-transfer progress as translated detail text, e.g. `"42% (12.60 kB/s, ETA: 5s)"` in English, with the
|
||||
* rate in decimal units.
|
||||
@@ -34,8 +38,8 @@ private const val PERCENT_MAX = 100
|
||||
* parentheses. Shared by the ESP32 OTA and Nordic DFU update handlers, which differ only in how they obtain the inputs.
|
||||
*/
|
||||
internal fun formatTransferProgress(progress: Float, totalBytes: Int, bytesPerSecond: Long): UiText {
|
||||
if (bytesPerSecond <= 0L) return formatTransferPercent(progress)
|
||||
val percent = (progress * PERCENT_MAX).toInt()
|
||||
if (bytesPerSecond <= 0L) return UiText.Resource(Res.string.firmware_update_transfer_percent, percent)
|
||||
val bytesSent = (progress * totalBytes).toLong()
|
||||
val etaSeconds = ((totalBytes - bytesSent).toFloat() / bytesPerSecond).toInt()
|
||||
return UiText.Resource(
|
||||
|
||||
+2
-3
@@ -55,6 +55,7 @@ import org.meshtastic.feature.firmware.FirmwareUpdateState
|
||||
import org.meshtastic.feature.firmware.ProgressState
|
||||
import org.meshtastic.feature.firmware.ota.ThroughputTracker
|
||||
import org.meshtastic.feature.firmware.ota.calculateMacPlusOne
|
||||
import org.meshtastic.feature.firmware.ota.formatTransferPercent
|
||||
import org.meshtastic.feature.firmware.ota.formatTransferProgress
|
||||
import org.meshtastic.feature.firmware.ota.retryWithDelay
|
||||
import org.meshtastic.feature.firmware.ota.scanForBleDevice
|
||||
@@ -62,7 +63,6 @@ import org.meshtastic.feature.firmware.stripFormatArgs
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
private const val PERCENT_MAX = 100
|
||||
private const val GATT_RELEASE_DELAY_MS = 1_500L
|
||||
private const val DFU_REBOOT_WAIT_MS = 3_000L
|
||||
private const val RETRY_DELAY_MS = 2_000L
|
||||
@@ -834,10 +834,9 @@ class SecureDfuHandler(
|
||||
|
||||
val path =
|
||||
firmwareRetriever.retrieveOtaFirmware(release, hardware) { progress ->
|
||||
val pct = (progress * PERCENT_MAX).toInt()
|
||||
updateState(
|
||||
FirmwareUpdateState.Downloading(
|
||||
ProgressState(UiText.DynamicString(downloadingMsg), progress, UiText.DynamicString("$pct%")),
|
||||
ProgressState(UiText.DynamicString(downloadingMsg), progress, formatTransferPercent(progress)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+31
-7
@@ -20,11 +20,14 @@ import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.model.FirmwareRelease
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.core.resources.firmware_update_transfer_percent
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.core.testing.TestDataFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
@@ -208,16 +211,37 @@ abstract class CommonPerformUsbUpdateTest {
|
||||
|
||||
val downloadingStates = states.filterIsInstance<FirmwareUpdateState.Downloading>()
|
||||
assertTrue(downloadingStates.size >= 2, "Expected multiple Downloading states for progress updates")
|
||||
assertTrue(
|
||||
downloadingStates.any { it.progressState.details == UiText.DynamicString("25%") },
|
||||
"Expected 25% progress detail",
|
||||
)
|
||||
assertTrue(
|
||||
downloadingStates.any { it.progressState.details == UiText.DynamicString("75%") },
|
||||
"Expected 75% progress detail",
|
||||
assertEquals(
|
||||
listOf(percentDetail(25), percentDetail(75)),
|
||||
downloadingStates.mapNotNull { it.progressState.details },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maintenance download reports progress as a translated percentage`() = runTest {
|
||||
val states = mutableListOf<FirmwareUpdateState>()
|
||||
|
||||
performUsbMaintenance(
|
||||
request = UsbMaintenanceRequest.FactoryErase,
|
||||
release = testRelease,
|
||||
hardware = testHardware,
|
||||
radioController = FakeRadioController(),
|
||||
nodeRepository = FakeNodeRepository(),
|
||||
updateState = { states.add(it) },
|
||||
retrieveUsbFirmware = { _, _, onProgress ->
|
||||
onProgress(0.4f)
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(percentDetail(40)),
|
||||
states.filterIsInstance<FirmwareUpdateState.Downloading>().mapNotNull { it.progressState.details },
|
||||
)
|
||||
}
|
||||
|
||||
private fun percentDetail(percent: Int) = UiText.Resource(Res.string.firmware_update_transfer_percent, percent)
|
||||
|
||||
@Test
|
||||
fun `download path returns artifact for caller cleanup`() = runTest {
|
||||
val radioController = FakeRadioController()
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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.ota
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.mock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.model.FirmwareRelease
|
||||
import org.meshtastic.core.repository.FirmwareUpdateStatusRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.core.resources.firmware_update_transfer_percent
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.feature.firmware.FirmwareArtifact
|
||||
import org.meshtastic.feature.firmware.FirmwareFileHandler
|
||||
import org.meshtastic.feature.firmware.FirmwareRetriever
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateState
|
||||
import org.meshtastic.feature.firmware.ota.dfu.SecureDfuHandler
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/** The download step of the ESP32 and Nordic handlers, which resolves compose resources and so runs on the JVM. */
|
||||
class DownloadProgressDetailTest {
|
||||
|
||||
private val release = FirmwareRelease(id = "v2.7.17", title = "test")
|
||||
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
|
||||
private val dispatchers =
|
||||
CoroutineDispatchers(
|
||||
io = Dispatchers.Unconfined,
|
||||
main = Dispatchers.Unconfined,
|
||||
default = Dispatchers.Unconfined,
|
||||
)
|
||||
|
||||
/** Reports a quarter of the download, then finds no file, so each handler stops right after its download step. */
|
||||
private val retriever =
|
||||
object : FirmwareRetriever(fileHandler) {
|
||||
override suspend fun retrieveEsp32Firmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): FirmwareArtifact? {
|
||||
onProgress(QUARTER)
|
||||
return null
|
||||
}
|
||||
|
||||
override suspend fun retrieveOtaFirmware(
|
||||
release: FirmwareRelease,
|
||||
hardware: DeviceHardware,
|
||||
onProgress: (Float) -> Unit,
|
||||
): FirmwareArtifact? {
|
||||
onProgress(QUARTER)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ESP32 download progress is a translated percentage`() = runTest {
|
||||
val handler =
|
||||
Esp32OtaUpdateHandler(
|
||||
firmwareRetriever = retriever,
|
||||
firmwareFileHandler = fileHandler,
|
||||
radioController = FakeRadioController(),
|
||||
nodeRepository = FakeNodeRepository(),
|
||||
firmwareUpdateStatusRepository = FirmwareUpdateStatusRepository(),
|
||||
environment = DefaultEsp32OtaUpdateEnvironment(),
|
||||
bleScanner = mock(MockMode.autofill),
|
||||
bleConnectionFactory = mock(MockMode.autofill),
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
val states = mutableListOf<FirmwareUpdateState>()
|
||||
|
||||
handler.startUpdate(release, ESP32, target = BLE_ADDRESS, updateState = states::add, firmwareUri = null)
|
||||
|
||||
assertEquals(listOf(percentDetail(25)), states.downloadDetails())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Nordic DFU download progress is a translated percentage`() = runTest {
|
||||
val handler =
|
||||
SecureDfuHandler(
|
||||
firmwareRetriever = retriever,
|
||||
firmwareFileHandler = fileHandler,
|
||||
radioController = FakeRadioController(),
|
||||
bleScanner = mock(MockMode.autofill),
|
||||
bleConnectionFactory = mock(MockMode.autofill),
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
val states = mutableListOf<FirmwareUpdateState>()
|
||||
|
||||
handler.startUpdate(release, NRF52, target = BLE_ADDRESS, updateState = states::add, firmwareUri = null)
|
||||
|
||||
assertEquals(listOf(percentDetail(25)), states.downloadDetails())
|
||||
}
|
||||
|
||||
private fun List<FirmwareUpdateState>.downloadDetails() =
|
||||
filterIsInstance<FirmwareUpdateState.Downloading>().mapNotNull { it.progressState.details }
|
||||
|
||||
private fun percentDetail(percent: Int) = UiText.Resource(Res.string.firmware_update_transfer_percent, percent)
|
||||
|
||||
private companion object {
|
||||
const val QUARTER = 0.25f
|
||||
const val BLE_ADDRESS = "AA:BB:CC:DD:EE:FF"
|
||||
val ESP32 = DeviceHardware(hwModelSlug = "HELTEC_V3", platformioTarget = "heltec-v3", architecture = "esp32-s3")
|
||||
val NRF52 = DeviceHardware(hwModelSlug = "RAK4631", platformioTarget = "rak4631", architecture = "nrf52840")
|
||||
}
|
||||
}
|
||||
+7
@@ -61,5 +61,12 @@ class TransferProgressTextTest {
|
||||
assertEquals("50%", formatTransferProgress(progress = 0.5f, totalBytes = 1000, bytesPerSecond = 0).resolve())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `download progress reads as the bare percentage`() = runTest {
|
||||
Locale.setDefault(Locale.US)
|
||||
|
||||
assertEquals("25%", formatTransferPercent(progress = 0.25f).resolve())
|
||||
}
|
||||
|
||||
private fun sample() = formatTransferProgress(progress = 0.5f, totalBytes = 2_000_000, bytesPerSecond = 12_600)
|
||||
}
|
||||
Reference in new issue
Block a user