diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md index 3821e02c2b..5221d06ab6 100644 --- a/.skills/testing-ci/SKILL.md +++ b/.skills/testing-ci/SKILL.md @@ -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. diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/pmtiles/OfflineRegionExtractor.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/pmtiles/OfflineRegionExtractor.kt index 8f1d9787e4..6ba200afc6 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/pmtiles/OfflineRegionExtractor.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/offline/pmtiles/OfflineRegionExtractor.kt @@ -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" diff --git a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt index 40f8205aa5..2ab396c6a5 100644 --- a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt @@ -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") /** diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt index bee3fe0567..8e10009244 100644 --- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt @@ -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()) } } /** diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index 48fd893c4c..ac9583c0b6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -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 -> diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index 86be126da0..d9270cfb7f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -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) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt index 505e3b32f8..a589605b79 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt @@ -450,6 +450,19 @@ class CommandSenderImplTest { verify { neighborInfoHandler.recordStartTime(1) } } + @Test + fun requestNeighborInfo_localNode_dummyReportsAnHourBroadcastIntervalInSeconds() = runTest { + every { neighborInfoHandler.lastNeighborInfo } returns null + val packets = mutableListOf() + 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()) } returns true diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt index 91de939f3a..b924005a56 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt @@ -260,7 +260,6 @@ class MeshMessageProcessorImplTest { advanceUntilIdle() assertEquals(listOf(myNodeNum, 999), updatedNodes) - verifySuspend(mode = VerifyMode.exactly(0)) { nodeManager.updateNodeAndPersist(any(), any(), any()) } } @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index 6075b7bbee..19c1a2743f 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -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 diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt index ac2b4a5e19..9c957e534d 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt @@ -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++ diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt index 4aa2838e59..11741975bf 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt @@ -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) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt index 3bfd047ad8..b41d4298b7 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt @@ -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), ), ), ) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index df818d8545..fec15c68b8 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -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), ), ), ) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareUpdateHelpers.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareUpdateHelpers.kt index fe8d63a442..0c65aec853 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareUpdateHelpers.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/FirmwareUpdateHelpers.kt @@ -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( diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt index 8045992a36..8eba78fcd5 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt @@ -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)), ), ) } diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt index e22c7c98f3..50e9faed1b 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt @@ -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() 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() + + 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().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() diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ota/DownloadProgressDetailTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ota/DownloadProgressDetailTest.kt new file mode 100644 index 0000000000..71546fc3bd --- /dev/null +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ota/DownloadProgressDetailTest.kt @@ -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 . + */ +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() + + 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() + + handler.startUpdate(release, NRF52, target = BLE_ADDRESS, updateState = states::add, firmwareUri = null) + + assertEquals(listOf(percentDetail(25)), states.downloadDetails()) + } + + private fun List.downloadDetails() = + filterIsInstance().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") + } +} diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ota/TransferProgressTextTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ota/TransferProgressTextTest.kt index da6be533b8..66fb1f911a 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ota/TransferProgressTextTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ota/TransferProgressTextTest.kt @@ -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) }