build: clear Kotlin warnings, time out hung tests, drop dead config (#7435)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-29 10:48:55 +00:00
1 parent b12ca979c8
commit 1b128ffc78
59 files changed
+499 -468

No files matched your search

-3
View File
@@ -44,9 +44,6 @@ ksp.incremental=false
# ── Android ──────────────────────────────────────────────────────────
android.experimental.lint.analysisPerComponent=true
# Disable unused build features to reduce build time
android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false
# ── Misc ─────────────────────────────────────────────────────────────
org.gradle.welcome=never
@@ -7,4 +7,4 @@ applyTo: "build-logic/**/*.kt"
- Prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs).
- Avoid `afterEvaluate` unless there is no viable lazy alternative.
- Check `gradle/libs.versions.toml` for version catalog aliases before adding new ones.
- 24 convention plugin ids are registered in `build-logic/convention/build.gradle.kts` — read that block rather than guessing. The ones module builds apply most often: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.library.compose`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`.
- 25 convention plugin ids are registered in `build-logic/convention/build.gradle.kts`. Read that block rather than guessing. The ones module builds apply most often: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.library.compose`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`.
+26
View File
@@ -157,6 +157,32 @@
"depNameTemplate": "gradle",
"datasourceTemplate": "gradle-version",
"versioningTemplate": "gradle"
},
{
"customType": "regex",
"description": "ktlint's version is a bare [versions] entry that only Spotless reads, so the gradle manager cannot tie it to an artifact.",
"managerFilePatterns": [
"/^gradle/libs\\.versions\\.toml$/"
],
"matchStrings": [
"\\nktlint\\s*=\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "com.pinterest.ktlint:ktlint-cli",
"datasourceTemplate": "maven",
"versioningTemplate": "maven"
},
{
"customType": "regex",
"description": "ktfmt's version is a bare [versions] entry that only Spotless reads, so the gradle manager cannot tie it to an artifact.",
"managerFilePatterns": [
"/^gradle/libs\\.versions\\.toml$/"
],
"matchStrings": [
"\\nktfmt\\s*=\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "com.facebook:ktfmt",
"datasourceTemplate": "maven",
"versioningTemplate": "maven"
}
]
}
+2 -2
View File
@@ -133,15 +133,15 @@ The tiers are named here and the workflows carry the label versions.
- `-Xmx4g` Gradle heap, `-Xmx6g` Kotlin daemon
- VFS watching disabled, workers capped at 4
- `org.gradle.isolated-projects=true` for better parallelism
- Disables unused Android build features (`resvalues`, `shaders`)
### 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`.
- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`.
- **`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.
- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` on SDK downloads. Cache key is `robolectric-{os}-{arch}-{hash of gradle/libs.versions.toml}`, restoring from the `robolectric-{os}-{arch}-` prefix, so a catalog change that bumps Robolectric rolls the key without a hand edit.
- **`mavenLocal()` gated:** Disabled by default to prevent CI cache poisoning. Pass `-PuseMavenLocal` for local JitPack testing.
- **JUnit parallel execution:** Enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races. Cross-module parallelism comes from Gradle forks (`maxParallelForks`).
- **Test timeouts:** every Jupiter test and lifecycle method fails after 2 minutes (`junit.jupiter.execution.timeout.default`, `SEPARATE_THREAD` so code that ignores interrupts still fails by name), and every `Test` task stops after 15 minutes, which also covers the JUnit 4 host tests. Both live in `ProjectExtensions.kt`.
- **Test retry:** Develocity plugin's native retry (`develocity.testRetry` on each Test task), configured in `ProjectExtensions.kt` (maxRetries=2, maxFailures=10). Screenshot tests opt out (maxRetries=0). The standalone `org.gradle.test-retry` plugin was removed.
- **`fail-fast: false`:** Test sharding does not cancel other shards on failure.
- **Explicit Gradle task paths:** Prefer `androidApp:lintFdroidDebug` over shorthand `lintDebug` in CI.
-2
View File
@@ -124,8 +124,6 @@ configure<ApplicationExtension> {
)
}
ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a") }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// Disable ABI splits for bundle builds or when explicitly requested via Gradle property.
@@ -88,6 +88,9 @@ fun WaypointMarkers(
val cleanName = waypoint.name.replace('\n', ' ').replace('\b', ' ')
val title = if (waypoint.isLocked) "${convertIntToEmoji(LOCK)} $cleanName" else cleanName
// rememberComposeBitmapDescriptor shares its applier with its content lambda, so the Text drawn into the
// icon makes this map-scope function infer as UI.
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
Marker(
state = markerState,
icon = icon,
@@ -14,8 +14,11 @@
* 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:OptIn(ExperimentalSerializationApi::class)
package org.meshtastic.app.map.offline.pmtiles
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
+1 -12
View File
@@ -14,28 +14,19 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.test)
alias(libs.plugins.meshtastic.android.test)
alias(libs.plugins.androidx.baselineprofile)
}
android {
namespace = "org.meshtastic.baselineprofile"
compileSdk = 37
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
defaultConfig {
// Macrobenchmark / BaselineProfileRule require API 28+ on the test (device) side.
// The generated profile is still installed on the app's real minSdk (26) via profileinstaller.
minSdk = 28
targetSdk = 37
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// App module whose startup we profile/benchmark.
@@ -48,8 +39,6 @@ android {
productFlavors { create("google") { dimension = "marketplace" } }
}
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
baselineProfile {
// Generate on an attached device/emulator. For hermetic CI, replace with a Gradle Managed
// Device (see README.md) and set managedDevices + useConnectedDevices = false.
+5 -5
View File
@@ -38,9 +38,6 @@ dependencies {
// This allows the use of the 'libs' type-safe accessor in the Kotlin source of the plugins
implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
// Self-updating embedded Gradle Kotlin version
val gradleKotlinVersion = KotlinVersion.CURRENT.toString()
// ── Convention plugin compile dependencies ──────────────────────────────
// These are standard compile-time dependencies used by our convention plugins.
compileOnly(libs.android.gradleApiPlugin)
@@ -137,13 +134,16 @@ gradlePlugin {
}
register("androidSecrets") {
id = "meshtastic.android.secrets"
implementationClass =
"com.google.android.libraries.mapsplatform.secrets_gradle_plugin.SecretsPlugin"
implementationClass = "com.google.android.libraries.mapsplatform.secrets_gradle_plugin.SecretsPlugin"
}
register("androidScreenshot") {
id = "meshtastic.android.screenshot"
implementationClass = "AndroidScreenshotConventionPlugin"
}
register("androidTest") {
id = "meshtastic.android.test"
implementationClass = "AndroidTestConventionPlugin"
}
register("androidApplicationCompose") {
id = "meshtastic.android.application.compose"
implementationClass = "AndroidApplicationComposeConventionPlugin"
@@ -46,11 +46,12 @@ class AboutLibrariesConventionPlugin : Plugin<Project> {
collect {
configPath.set(licenseConfigDir)
fetchRemoteLicense.set(isReleaseBuild && ghToken.isPresent)
fetchRemoteFunding.set(isReleaseBuild && ghToken.isPresent)
if (ghToken.isPresent) {
gitHubApiToken.set(ghToken.get())
}
// Wired as providers, never read here: CI issues a new token per step, and a token read at
// configuration time becomes a configuration cache input that no later step can reuse.
val fetchRemote = ghToken.map { isReleaseBuild }.orElse(false)
fetchRemoteLicense.set(fetchRemote)
fetchRemoteFunding.set(fetchRemote)
gitHubApiToken.set(ghToken)
}
export {
excludeFields.set(listOf("generated"))
@@ -51,8 +51,6 @@ class AndroidLintConventionPlugin : Plugin<Project> {
}
private fun Lint.configure(@Suppress("UnusedParameter") project: Project) {
xmlReport = true
sarifReport = true
checkDependencies = true
abortOnError = false
disable += "GradleDependency"
@@ -0,0 +1,32 @@
/*
* 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/>.
*/
import com.android.build.api.dsl.TestExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.meshtastic.buildlogic.configureKotlinAndroidTest
class AndroidTestConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.test")
extensions.configure<TestExtension> { configureKotlinAndroidTest(this) }
}
}
}
@@ -19,19 +19,24 @@ package org.meshtastic.buildlogic
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
import com.android.build.api.dsl.TestExtension
import dev.mokkery.gradle.MokkeryGradleExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.findByType
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompilerOptions
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyTemplate
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
/** Configure base Kotlin with Android options */
@@ -44,7 +49,6 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
compileSdk = compileSdkVersion
defaultConfig.minSdk = minSdkVersion
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
if (this is ApplicationExtension) {
defaultConfig.targetSdk = targetSdkVersion
@@ -64,6 +68,19 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
configureKotlin<KotlinAndroidProjectExtension>()
}
/** Configure SDK and JVM levels for `com.android.test` modules; each module keeps its own minSdk. */
internal fun Project.configureKotlinAndroidTest(testExtension: TestExtension) {
testExtension.apply {
compileSdk = configProperties.getProperty("COMPILE_SDK").toInt()
defaultConfig.targetSdk = configProperties.getProperty("TARGET_SDK").toInt()
compileOptions.sourceCompatibility = JavaVersion.VERSION_21
compileOptions.targetCompatibility = JavaVersion.VERSION_21
}
configureKotlin<KotlinAndroidProjectExtension>()
}
/**
* Whether the current build host can run the Kotlin/Native compiler.
*
@@ -216,14 +233,22 @@ internal fun Project.configureKotlinJvm() {
configureKotlin<KotlinJvmProjectExtension>()
}
/**
* `-PwarningsAsErrors=true` turns every Kotlin warning into an error. Modules that configure Kotlin outside these
* conventions (desktopApp, schema-strings) read it too.
*/
val Project.kotlinWarningsAsErrors: Provider<Boolean>
get() = providers.gradleProperty("warningsAsErrors").map { it.toBoolean() }.orElse(false)
/** Compiler args shared across all Kotlin targets (JVM, Android, iOS, etc.). */
private val SHARED_COMPILER_ARGS =
listOf(
"-Xexpect-actual-classes",
"-Xskip-prerelease-check",
// No -Xbackend-threads: parallel codegen races and crashes release builds (KT-83578).
)
private const val SHARED_OPT_IN = "kotlinx.coroutines.ExperimentalCoroutinesApi"
private const val JDK_VERSION = 25
/** Configure base Kotlin options */
@@ -233,14 +258,13 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
if (this is KotlinMultiplatformExtension) {
targets.configureEach {
val isJvmTarget = platformType.name == "jvm" || platformType.name == "androidJvm"
compilations.configureEach {
compileTaskProvider.configure {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
optIn.add(SHARED_OPT_IN)
freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
if (isJvmTarget) {
freeCompilerArgs.add("-jvm-default=no-compatibility")
if (this is KotlinJvmCompilerOptions) {
jvmDefault.set(JvmDefaultMode.NO_COMPATIBILITY)
}
}
}
@@ -249,19 +273,23 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
}
}
val warningsAsErrors = providers.gradleProperty("warningsAsErrors").map { it.toBoolean() }.getOrElse(false)
val warningsAsErrors = kotlinWarningsAsErrors
// Every compilation, native and metadata included, so a gated build also fails on iosMain-only warnings.
tasks.withType<KotlinCompilationTask<*>>().configureEach {
compilerOptions.allWarningsAsErrors.set(warningsAsErrors)
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
allWarningsAsErrors.set(warningsAsErrors)
// For non-KMP modules, configure compiler args here since they don't use targets.compilations.
// KMP modules already set these via the targets block above — only jvmTarget/warnings needed here.
// KMP modules already set these via the targets block above; only jvmTarget is needed here.
if (T::class != KotlinMultiplatformExtension::class) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
optIn.add(SHARED_OPT_IN)
freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
freeCompilerArgs.add("-jvm-default=no-compatibility")
jvmDefault.set(JvmDefaultMode.NO_COMPATIBILITY)
}
}
}
@@ -30,10 +30,12 @@ import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.withType
import org.gradle.plugin.use.PluginDependency
import java.io.FileInputStream
import java.time.Duration
import java.util.Properties
private const val MAX_TEST_RETRIES = 2
private const val MAX_TEST_FAILURES = 10
private const val TEST_TASK_TIMEOUT_MINUTES = 15L
val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
@@ -74,6 +76,8 @@ internal fun Project.configureTestOptions() {
project.dependencies.add(name, launcher)
}
val isCi = providers.gradleProperty("ci").map { it.toBoolean() }.getOrElse(false)
tasks.withType<Test>().configureEach {
// JUnit 5: activate JUnit Platform — but NOT for androidHostTest (Robolectric) tasks
// in KMP modules. Those tasks run JUnit 4 natively; applying useJUnitPlatform()
@@ -84,7 +88,6 @@ internal fun Project.configureTestOptions() {
}
// Parallelize unit tests at the Gradle fork level.
// In CI, use all available processors; locally use half to keep the machine responsive.
val isCi = project.findProperty("ci") == "true"
maxParallelForks =
if (isCi) {
Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
@@ -99,12 +102,23 @@ internal fun Project.configureTestOptions() {
systemProperty("java.awt.headless", "true")
jvmArgs("-Dapple.awt.UIElement=true")
// JDK 24+ warns on every System.load from the class path (bundled SQLite, Skiko) unless native access is on.
jvmArgs("--enable-native-access=ALL-UNNAMED")
// Numbers and units format in the OS locale, so the forked test JVMs are pinned to one: otherwise a
// contributor whose machine defaults to de-DE gets "0,0°C" and fails every test that pins "0.0°C".
// Locale-specific behaviour is asserted by tests that set the locale themselves.
systemProperty("user.language", "en")
systemProperty("user.country", "US")
// A hung test fails by name instead of running into the CI job timeout. SEPARATE_THREAD lets the timeout
// fire even when the stuck code never checks for interruption.
systemProperty("junit.jupiter.execution.timeout.default", "2 m")
systemProperty("junit.jupiter.execution.timeout.thread.mode.default", "SEPARATE_THREAD")
systemProperty("junit.jupiter.execution.timeout.mode", "disabled_on_debug")
// Backstop for JUnit 4 host tests, which Jupiter's timeout does not reach, and for a wedged test JVM.
timeout.set(Duration.ofMinutes(TEST_TASK_TIMEOUT_MINUTES))
// JUnit Jupiter parallel execution within each Gradle fork.
// Classes run sequentially ("same_thread") because 19+ ViewModel test classes use
// Dispatchers.setMain() — a JVM-global singleton that races when classes execute
+3 -16
View File
@@ -15,21 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# Gradle properties for the build-logic included build.
# These need to be set separately because properties are not passed to included builds.
# https://github.com/gradle/gradle/issues/2534
# Project properties for the build-logic included build, which does not see the root's.
# Gradle runtime properties (org.gradle.*) are read from the root build only and are
# ignored here: https://docs.gradle.org/current/userguide/composite_builds.html
org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGC -Dfile.encoding=UTF-8
# Parallelism & Caching
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.isolated-projects=true
org.gradle.configureondemand=false
# Kotlin
kotlin.code.style=official
# Housekeeping
org.gradle.welcome=never
@@ -16,7 +16,6 @@
*/
package org.meshtastic.core.ble
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
@@ -146,8 +145,8 @@ class AndroidBluetoothRepositoryBondTest {
// Make the device already bonded both at the adapter level (so isBonded is observable) and per-device
// (so bond() hits the early BOND_BONDED guard at line 85 without ever calling createBond()).
RobolectricBleBonding.primeBond(mac, bondState = BluetoothDevice.BOND_BONDED, createBondReturns = false)
shadowOf(BluetoothAdapter.getDefaultAdapter())
.setBondedDevices(setOf(BluetoothAdapter.getDefaultAdapter().getRemoteDevice(mac)))
val adapter = RobolectricBleBonding.adapter
shadowOf(adapter).setBondedDevices(setOf(adapter.getRemoteDevice(mac)))
val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
assertNull(launchBond(repo, mac).await(), "an already-bonded device should return without error")
@@ -401,8 +400,8 @@ class AndroidBluetoothRepositoryBondTest {
val bondedMac = "AA:BB:CC:DD:EE:06"
val otherMac = "AA:BB:CC:DD:EE:07"
RobolectricBleBonding.grantBluetoothConnectPermission()
val bondedDevice = BluetoothAdapter.getDefaultAdapter().getRemoteDevice(bondedMac)
shadowOf(BluetoothAdapter.getDefaultAdapter()).setBondedDevices(setOf(bondedDevice))
val adapter = RobolectricBleBonding.adapter
shadowOf(adapter).setBondedDevices(setOf(adapter.getRemoteDevice(bondedMac)))
val repo = newRepository(UnconfinedTestDispatcher(testScheduler))
assertTrue(repo.isBonded(bondedMac))
@@ -25,6 +25,7 @@ import okio.ByteString
* device public key: `my_node_num = crc32(config.security.public_key)` (NodeDB::createNewIdentity). Lets the app
* recognize a pubkey-derived ("canonical") node number when a node reappears under a new num after a firmware upgrade.
*/
@OptIn(ExperimentalUnsignedTypes::class)
object Crc32 {
private const val POLYNOMIAL: UInt = 0xEDB88320u
private const val INITIAL: UInt = 0xFFFFFFFFu
@@ -467,7 +467,6 @@ class MeshConnectionManagerImplTest {
admissionVersions += call.arg<Long>(1)
seedAttempts++
if (seedAttempts < 3) throw PacketQueueRejectedException("test passkey seed")
Unit
}
everySuspend { commandSender.requestTelemetryForConnection(any(), any(), any(), any()) } calls
{ call ->
@@ -476,14 +475,12 @@ class MeshConnectionManagerImplTest {
val attempts = telemetryAttempts.getOrElse(type) { 0 } + 1
telemetryAttempts[type] = attempts
if (attempts == 1) throw PacketQueueRejectedException("test telemetry request")
Unit
}
everySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any(), any()) } calls
{ call ->
admissionVersions += call.arg<Long>(4)
historyAttempts++
if (historyAttempts == 1) throw PacketQueueRejectedException("test history replay")
Unit
}
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
every { mqttManager.startProxy(any(), any()) } returns Unit
@@ -37,6 +37,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -1263,6 +1264,7 @@ open class DatabaseManager(private val datastore: DatabaseDataStore, private val
* only once it is about to invoke the callback — a call still waiting for its lane has performed no side effect and
* is aborted at the cancellation check instead of running late.
*/
@OptIn(DelicateCoroutinesApi::class)
private fun <T> launchDbBlock(
admission: AdmittedDatabase,
blockStarted: CompletableDeferred<Unit>,
@@ -152,7 +152,7 @@ abstract class CommonNodeInfoDaoTest {
assertEquals(trusted, stored?.publicKey)
assertEquals(trusted, stored?.user?.public_key)
assertFalse(stored?.keyMatch ?: true)
assertEquals(substitute, stored?.newPublicKey)
assertEquals(substitute, stored.newPublicKey)
}
@Test
@@ -207,7 +207,7 @@ abstract class CommonNodeInfoDaoTest {
val stillFlagged = dao.getNodeByNum(1)?.node
assertEquals(trusted, stillFlagged?.publicKey)
assertFalse(stillFlagged?.keyMatch ?: true)
assertEquals(substitute, stillFlagged?.newPublicKey)
assertEquals(substitute, stillFlagged.newPublicKey)
}
@Test
@@ -262,7 +262,7 @@ abstract class CommonNodeInfoDaoTest {
val stored = dao.getNodeByNum(own)?.node
assertEquals(after, stored?.publicKey)
assertTrue(stored?.keyMatch ?: false)
assertEquals(null, stored?.newPublicKey)
assertEquals(null, stored.newPublicKey)
}
@Test
@@ -299,7 +299,7 @@ abstract class CommonNodeInfoDaoTest {
val remote = dao.getNodeByNum(1)?.node
assertEquals(trusted, remote?.publicKey)
assertTrue(remote?.keyMatch ?: false)
assertEquals(null, remote?.newPublicKey)
assertEquals(null, remote.newPublicKey)
// Nor may the local link write it over the connected radio's real key. A key of its own, or the new-node
// guard would read this upsert as node 1 claiming a second number and never insert it.
@@ -447,7 +447,7 @@ abstract class CommonNodeInfoDaoTest {
val stored = dao.getNodeByNum(own)
assertEquals(real, stored?.node?.publicKey)
assertFalse(stored?.node?.keyMatch ?: true)
assertTrue(stored!!.toModel().mismatchKey)
assertTrue(stored.toModel().mismatchKey)
}
@Test
-4
View File
@@ -90,8 +90,6 @@ core:domain
├── core:model (domain models)
├── org.meshtastic:protobufs (Meshtastic protobuf types, Maven)
├── core:common
├── core:database
├── core:datastore
└── core:resources
```
@@ -110,8 +108,6 @@ graph TB
:core:domain -.-> :core:repository
:core:domain -.-> :core:model
:core:domain -.-> :core:common
:core:domain -.-> :core:database
:core:domain -.-> :core:datastore
:core:domain -.-> :core:resources
:core:domain -.-> :core:testing
-2
View File
@@ -30,8 +30,6 @@ kotlin {
implementation(projects.core.model)
implementation(libs.meshtastic.protobufs)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.resources)
implementation(libs.kermit)
@@ -105,11 +105,7 @@ class EnsureRemoteAdminSessionUseCaseTest {
val sessionManager = stubSessionManager(refreshFlow = refresh)
val controller = mock<RadioController>(MockMode.autofill)
// Simulate the radio responding by emitting on the refresh flow when the metadata request fires.
everySuspend { controller.refreshMetadata(any()) } calls
{
refresh.tryEmit(destNum)
Unit
}
everySuspend { controller.refreshMetadata(any()) } calls { refresh.tryEmit(destNum) }
val useCase =
EnsureRemoteAdminSessionUseCase(sessionManager, controller, connectedRepo(), this.asServiceScope())
@@ -167,11 +163,7 @@ class EnsureRemoteAdminSessionUseCaseTest {
val sessionManager = stubSessionManager(refreshFlow = refresh)
val controller = mock<RadioController>(MockMode.autofill)
var dispatches = 0
everySuspend { controller.refreshMetadata(any()) } calls
{
dispatches++
Unit
}
everySuspend { controller.refreshMetadata(any()) } calls { dispatches++ }
val useCase =
EnsureRemoteAdminSessionUseCase(sessionManager, controller, connectedRepo(), this.asServiceScope())
@@ -195,11 +187,7 @@ class EnsureRemoteAdminSessionUseCaseTest {
val sessionManager = stubSessionManager(refreshFlow = refresh)
val controller = mock<RadioController>(MockMode.autofill)
var dispatches = 0
everySuspend { controller.refreshMetadata(any()) } calls
{
dispatches++
Unit
}
everySuspend { controller.refreshMetadata(any()) } calls { dispatches++ }
val useCase =
EnsureRemoteAdminSessionUseCase(sessionManager, controller, connectedRepo(), this.asServiceScope())
@@ -224,11 +212,7 @@ class EnsureRemoteAdminSessionUseCaseTest {
val sessionManager = stubSessionManager(refreshFlow = refresh)
val controller = mock<RadioController>(MockMode.autofill)
var dispatches = 0
everySuspend { controller.refreshMetadata(any()) } calls
{
dispatches++
Unit
}
everySuspend { controller.refreshMetadata(any()) } calls { dispatches++ }
val connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Connected)
val repository = mock<ServiceRepository>(MockMode.autofill)
every { repository.connectionState } returns connectionState
+1
View File
@@ -22,6 +22,7 @@ plugins {
}
kotlin {
// No withHostTest: commonTest reaches android.net.Uri and Bundle, which the host-test stubs do not implement.
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
@@ -169,7 +169,7 @@ class MockRadioTransport(
}
data != null && data.portnum == PortNum.TEXT_MESSAGE_APP -> {
if (packet?.want_ack == true) sendFakeAck(pr)
if (packet.want_ack) sendFakeAck(pr)
sendSimulatedReply(packet)
}
@@ -607,7 +607,7 @@ internal fun effectiveCredentials(config: ModuleConfig.MQTTConfig?): Pair<String
if (config?.address.isNullOrEmpty()) {
DEFAULT_MQTT_USERNAME to DEFAULT_MQTT_PASSWORD
} else {
config?.username to config?.password
config.username to config.password
}
fun effectiveTlsEnabled(address: String, tlsEnabled: Boolean): Boolean =
@@ -20,8 +20,10 @@ import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Parcelable
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import androidx.core.os.BundleCompat
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import dev.mokkery.MockMode
@@ -361,8 +363,10 @@ class MeshNotificationManagerImplConversationTest {
advanceUntilIdle()
val posted = activeByTag("message").single().notification
val alerting = posted.extras.getParcelableArray(Notification.EXTRA_MESSAGES)
val historic = posted.extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES)
val alerting =
BundleCompat.getParcelableArray(posted.extras, Notification.EXTRA_MESSAGES, Parcelable::class.java)
val historic =
BundleCompat.getParcelableArray(posted.extras, Notification.EXTRA_HISTORIC_MESSAGES, Parcelable::class.java)
assertEquals(1, alerting?.size, "only the unread message should be presented as new content")
assertEquals(2, historic?.size, "read context should be carried as historic messages")
assertEquals("new unread", posted.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString())
@@ -429,7 +433,9 @@ class MeshNotificationManagerImplConversationTest {
val posted = activeByTag("message").single().notification
// Empty primary channel resolves to its modem-preset display name, matching the in-app conversation list.
assertEquals("LongFast", posted.extras.getCharSequence(Notification.EXTRA_CONVERSATION_TITLE)?.toString())
assertNotNull(posted.extras.getParcelableArray(Notification.EXTRA_MESSAGES))
assertNotNull(
BundleCompat.getParcelableArray(posted.extras, Notification.EXTRA_MESSAGES, Parcelable::class.java),
)
}
@Test
@@ -219,16 +219,8 @@ class MeshServiceOrchestratorTest {
val takEnabledFlow = MutableStateFlow(false)
val takRunningFlow = MutableStateFlow(false)
val lifecycleEvents = mutableListOf<String>()
every { takServerManager.start(any()) } calls
{
lifecycleEvents += "start"
Unit
}
every { takServerManager.stop() } calls
{
lifecycleEvents += "stop"
Unit
}
every { takServerManager.start(any()) } calls { lifecycleEvents += "start" }
every { takServerManager.stop() } calls { lifecycleEvents += "stop" }
val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow)
orchestrator.start()
@@ -248,16 +240,8 @@ class MeshServiceOrchestratorTest {
val takEnabledFlow = MutableStateFlow(true)
val takRunningFlow = MutableStateFlow(false)
val lifecycleEvents = mutableListOf<String>()
every { takServerManager.start(any()) } calls
{
lifecycleEvents += "start"
Unit
}
every { takServerManager.stop() } calls
{
lifecycleEvents += "stop"
Unit
}
every { takServerManager.start(any()) } calls { lifecycleEvents += "start" }
every { takServerManager.stop() } calls { lifecycleEvents += "stop" }
val orchestrator = createOrchestrator(takEnabledFlow = takEnabledFlow, takRunningFlow = takRunningFlow)
orchestrator.start()
@@ -20,6 +20,7 @@ import android.Manifest
import android.app.Application
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.content.Intent
import android.os.Looper
import org.robolectric.RuntimeEnvironment
@@ -49,12 +50,9 @@ object RobolectricBleBonding {
private val application: Application
get() = RuntimeEnvironment.getApplication()
/**
* The default adapter Robolectric exposes; production resolves the same one via
* [android.bluetooth.BluetoothManager].
*/
private val adapter: BluetoothAdapter
get() = BluetoothAdapter.getDefaultAdapter()
/** The adapter Robolectric exposes through [BluetoothManager], the same one production resolves. */
val adapter: BluetoothAdapter
get() = application.getSystemService(BluetoothManager::class.java).adapter
/** Grant the runtime permissions [ShadowBluetoothDevice.createBond] checks, so it returns instead of throwing. */
fun grantBluetoothConnectPermission() {
@@ -126,6 +126,6 @@ class FakeBleServiceFailureInjectionTest {
assertFalse(subscribed, "onSubscription must not run for never-subscribe characteristic")
assertNotNull(received, "Notifications must still flow through the bare SharedFlow")
assertTrue(received!!.contentEquals(byteArrayOf(1, 2, 3)), "Notification payload must be exposed verbatim")
assertTrue(received.contentEquals(byteArrayOf(1, 2, 3)), "Notification payload must be exposed verbatim")
}
}
+2
View File
@@ -24,6 +24,8 @@ plugins {
}
kotlin {
// No withHostTest: commonTest holds Compose UI tests, which NPE on the host-test stubs' null Build.FINGERPRINT.
// Required for CMP files/ resources (emoji-data.json) to be packaged as Android assets.
// Without this, Res.readBytes() throws MissingResourceException at runtime.
android { androidResources.enable = true }
@@ -47,7 +47,7 @@ fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit, isOwnContact: Boo
title = stringResource(Res.string.share_contact),
uriString = uriString,
onDismiss = onDismiss,
subtitle = contact.user?.long_name,
subtitle = contact.user.long_name,
shareSubject = stringResource(Res.string.share_contact_subject),
)
}
@@ -31,6 +31,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
@@ -193,7 +194,7 @@ fun QrDialog(
// Copy stays an icon: it is the secondary of the three, and a tooltip carries the
// label on desktop, where standards section 4 asks for one on an icon-only control.
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = { PlainTooltip { Text(stringResource(Res.string.copy)) } },
state = rememberTooltipState(),
) {
+4 -2
View File
@@ -20,8 +20,10 @@ import org.gradle.api.file.FileSystemOperations
import org.gradle.process.ExecOperations
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask
import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.meshtastic.buildlogic.configureGraphTasks
import org.meshtastic.buildlogic.kotlinWarningsAsErrors
import org.meshtastic.buildlogic.maplibreDesktopRuntime
import org.meshtastic.buildlogic.resolveVersionInfo
import java.io.File
@@ -113,7 +115,8 @@ kotlin {
}
compilerOptions {
jvmTarget.set(JvmTarget.JVM_25)
freeCompilerArgs.add("-jvm-default=no-compatibility")
jvmDefault.set(JvmDefaultMode.NO_COMPATIBILITY)
allWarningsAsErrors.set(kotlinWarningsAsErrors)
}
}
@@ -420,7 +423,6 @@ dependencies {
implementation(libs.androidx.datastore)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.jna)
@@ -94,7 +94,7 @@ class DesktopNotificationManagerTest {
assertTrue(dispatched, "Tray fallback acceptance should count as delivery-accepted")
assertNotNull(fallback, "Expected fallback notification to be emitted")
assertEquals("Fallback", fallback!!.title)
assertEquals("Fallback", fallback.title)
}
@Test
-1
View File
@@ -55,5 +55,4 @@ UI for port-37 Mesh Beacon join invitations (`ui/component/`):
From `feature/discovery/build.gradle.kts` (`commonMain`):
- `core:common`, `core:data`, `core:database`, `core:di`, `core:model`, `core:navigation`, `core:network`, `core:prefs`, `core:repository`, `core:resources`, `core:service`, `core:ui`
- `kotlinx.collections.immutable`
- `org.meshtastic:protobufs` (Maven artifact)
-1
View File
@@ -45,7 +45,6 @@ kotlin {
implementation(projects.core.service)
implementation(projects.core.ui)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.meshtastic.protobufs)
}
}
@@ -771,7 +771,7 @@ class DiscoveryScanEngineTest {
val state = engine.scanState.value
assertTrue(state is DiscoveryScanState.Complete, "expected Complete, was $state")
assertEquals(DiscoveryScanState.CompletionOutcome.Success, (state as DiscoveryScanState.Complete).outcome)
assertEquals(DiscoveryScanState.CompletionOutcome.Success, state.outcome)
assertEquals(1, radioController.neighborInfoRequests.size)
assertEquals("complete", discoveryDao.sessions.values.single().completionStatus)
}
@@ -787,7 +787,7 @@ class DiscoveryScanEngineTest {
val state = engine.scanState.value
assertTrue(state is DiscoveryScanState.Complete, "expected Complete, was $state")
assertEquals(DiscoveryScanState.CompletionOutcome.Success, (state as DiscoveryScanState.Complete).outcome)
assertEquals(DiscoveryScanState.CompletionOutcome.Success, state.outcome)
assertEquals(1, radioController.neighborInfoRequests.size)
assertEquals("complete", discoveryDao.sessions.values.single().completionStatus)
}
@@ -809,7 +809,7 @@ class DiscoveryScanEngineTest {
// none of which happened before the fix, because cancelScanInternal() cancelled this coroutine first.
val state = engine.scanState.value
assertTrue(state is DiscoveryScanState.Complete, "expected Complete, was $state")
assertEquals(DiscoveryScanState.CompletionOutcome.Failed, (state as DiscoveryScanState.Complete).outcome)
assertEquals(DiscoveryScanState.CompletionOutcome.Failed, state.outcome)
assertFalse(engine.isActive)
assertNull(collectorRegistry.collector, "collector should be unregistered")
@@ -928,7 +928,7 @@ class DiscoveryScanEngineTest {
val state = engine.scanState.value
assertTrue(state is DiscoveryScanState.Complete, "expected Complete, was $state")
assertEquals(DiscoveryScanState.CompletionOutcome.Success, (state as DiscoveryScanState.Complete).outcome)
assertEquals(DiscoveryScanState.CompletionOutcome.Success, state.outcome)
assertEquals(DiscoverySessionStatus.COMPLETE, discoveryDao.sessions.values.first().completionStatus)
}
@@ -1002,7 +1002,7 @@ class DiscoveryScanEngineTest {
val state = engine.scanState.value
assertTrue(state is DiscoveryScanState.Complete, "expected Complete, was $state")
assertEquals(DiscoveryScanState.CompletionOutcome.Failed, (state as DiscoveryScanState.Complete).outcome)
assertEquals(DiscoveryScanState.CompletionOutcome.Failed, state.outcome)
assertEquals(DiscoverySessionStatus.FAILED, discoveryDao.sessions.values.first().completionStatus)
assertEquals(ChannelOption.LONG_FAST.modemPreset, radioController.lastLocalConfig?.lora?.modem_preset)
assertTrue(
@@ -1162,7 +1162,7 @@ class DiscoveryScanEngineTest {
val state = engine.scanState.value
assertTrue(state is DiscoveryScanState.Complete, "expected Complete, was $state")
assertEquals(DiscoveryScanState.CompletionOutcome.Success, (state as DiscoveryScanState.Complete).outcome)
assertEquals(DiscoveryScanState.CompletionOutcome.Success, state.outcome)
assertEquals(DiscoverySessionStatus.COMPLETE, discoveryDao.sessions.values.first().completionStatus)
assertEquals(ChannelOption.LONG_FAST.modemPreset, radioController.lastLocalConfig?.lora?.modem_preset)
}
+1 -2
View File
@@ -134,8 +134,7 @@ feature:docs
├── core:common, core:navigation, core:resources, core:ui, core:di
├── coil (image loading in Markdown)
├── markdown-renderer-m3 (Compose Markdown rendering)
├── compose.material3.adaptive, compose.material3.adaptive.navigation3
└── kotlinx.collections.immutable
└── compose.material3.adaptive, compose.material3.adaptive.navigation3
```
<!--region graph-->
+1 -1
View File
@@ -22,6 +22,7 @@ plugins {
}
kotlin {
// No withHostTest: commonTest holds Compose UI tests, which NPE on the host-test stubs' null Build.FINGERPRINT.
android {
namespace = "org.meshtastic.feature.docs"
androidResources.enable = true
@@ -35,7 +36,6 @@ kotlin {
implementation(projects.core.ui)
implementation(projects.core.di)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
implementation(libs.coil)
@@ -96,7 +96,7 @@ class TranslationCascadeTest {
assertIs<TranslationResult.Success>(success)
assertIs<TranslationResult.ModelDownloadRequired>(download)
assertIs<TranslationResult.Unavailable>(unavailable)
assertEquals("es", (download as TranslationResult.ModelDownloadRequired).locale)
assertEquals("es", download.locale)
}
}
-1
View File
@@ -45,7 +45,6 @@ kotlin {
implementation(projects.core.ui)
implementation(libs.coil)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.ktor.client.core)
implementation(libs.ktor.network)
implementation(libs.markdown.renderer)
@@ -35,115 +35,116 @@ import kotlin.test.assertTrue
*/
abstract class CommonMaintenanceVolumeTest {
private val manifestJson = Json { ignoreUnknownKeys = true }
/** The real maintenance-UF2 manifest, embedded verbatim — see UsbMaintenanceGateTest for why. */
private val testManifest =
Json { ignoreUnknownKeys = true }
.decodeFromString<MaintenanceUf2Manifest>(
"""
{
"manifestVersion": 1,
"otafixReleaseTag": "0.9.2-OTAFIX2.3-BP1.5",
"otafixBase": "https://github.com/meshtastic/Adafruit_nRF52_Bootloader_OTAFIX/releases/download/0.9.2-OTAFIX2.3-BP1.5",
"erase": {
"nrf52": {
"6.1.1": {
"fileName": "nrf_erase2.uf2",
"sha256": "4b778a3def19854415db64cb51bfd29c15b11cc46006353dd518f62d09efe3fe",
"expectedFirstTargetAddress": 155648
},
"7.3.0": {
"fileName": "nrf_erase_sd7_3.uf2",
"sha256": "13941bedce009e61255c37b1524d11ca604e88c38e7588bb8b391e2998da468f",
"expectedFirstTargetAddress": 159744
}
},
"nrf52Bootloader": {
"fileName": "meshtastic_factory_erase.uf2",
"sha256": "6ef3146505c40079ee9e7e692448e40a793dad636f55d1545063299d28908f0d",
"expectedFamilyId": 1296388936
},
"rp2040": {
"fileName": "pico_erase.uf2",
"sha256": "08aa7d561e8b8bf2f9b061b3506fb4d8f135e832efe0f3ae978241db2da0c853"
}
manifestJson.decodeFromString<MaintenanceUf2Manifest>(
"""
{
"manifestVersion": 1,
"otafixReleaseTag": "0.9.2-OTAFIX2.3-BP1.5",
"otafixBase": "https://github.com/meshtastic/Adafruit_nRF52_Bootloader_OTAFIX/releases/download/0.9.2-OTAFIX2.3-BP1.5",
"erase": {
"nrf52": {
"6.1.1": {
"fileName": "nrf_erase2.uf2",
"sha256": "4b778a3def19854415db64cb51bfd29c15b11cc46006353dd518f62d09efe3fe",
"expectedFirstTargetAddress": 155648
},
"otafixByBoardId": {
"HT-n5262": {
"otafixBoardSlug": "heltec_t114",
"sha256": "ae92d3577cb58dd9b43c9b61ffb9bfffda05b0eca4113a0ec42a37cd8be53b19"
},
"MinewSemi-MX25LE01": {
"otafixBoardSlug": "minewsemi_mx25le01",
"sha256": "e09564fd8dd03fc25d76dcb732a0214c79653da3b130240949b783254d3dfc1b"
},
"TRACKER L1": {
"otafixBoardSlug": "wio_tracker_l1",
"sha256": "70fbce0eda9d70d7bd8a4367057badf5ec310838bf3221370d45a56f04956b9e"
},
"WisBlock-RAK4631-Board": {
"otafixBoardSlug": "wiscore_rak4631_board",
"sha256": "8741bc677a3c24f28422c5ffb80761de7d98a127a3b0191ba6585bf57ce9f305"
},
"WisMesh-Tag": {
"otafixBoardSlug": "wismesh_tag",
"sha256": "96d42e1990e17251e8c625e98a1551cac12c6e29111bc2e59ab7c9fe6dec8758"
},
"nRF52840-SeeedSenseCAPSolarP1-v1": {
"otafixBoardSlug": "sensecap_solar_p1",
"sha256": "9b4bce48c1b4830617715c5619457bce6b21f3079803e35e13433de7701290f5"
},
"nRF52840-SeeedXiao-v1": {
"otafixBoardSlug": "xiao_nrf52840_ble",
"sha256": "ff8a0916e98cceb394fd66590bccc17f63612c11ff56b086ef88bd436c8df67f"
},
"nRF52840-SeeedXiaoSense-v1": {
"otafixBoardSlug": "xiao_nrf52840_ble_sense",
"sha256": "fc233d83a1011419625fcb50b49084578460c25bbc0270374ca176757a3c40da"
},
"nRF52840-T1000-E-v1": {
"otafixBoardSlug": "t1000_e",
"sha256": "5c065e11b8acd5b0cefa9295f98bca1512306cfa478856aa76a871124a904cc4"
},
"nRF52840-TEcho-v1": {
"otafixBoardSlug": "lilygo_techo",
"sha256": "2ddb36188ffe521c270bb2ce8441d742d0fe45325c57e4db6475bf63162a59b0"
},
"nRF52840-ThinkNode-M3-v1": {
"otafixBoardSlug": "thinknode_m3",
"sha256": "bf90979f2f6adc96ef6ca09c280b2ab7e66cb8ce2654fc80da9b20407bfb8708"
},
"nRF52840-ThinkNodeM1-v1": {
"otafixBoardSlug": "thinknode_m1",
"sha256": "aa0721b573c60e0b179274d5a5296bac7a8436faf339cfc03116ebe8a4375795"
},
"nRF52840-ThinkNodeM6-v1": {
"otafixBoardSlug": "thinknode_m6",
"sha256": "aaf94953a540a18f3e48f4cdec0c78290ad3c5f8740aea26fa3b3ce3632a8d4a"
},
"nRF52840-promicro": {
"otafixBoardSlug": "promicro_nrf52840",
"sha256": "46ef3440f151d6f2606075bcd1aa83db25a660da7d25b988aeb47ef350c98794"
}
},
"otafixSupportedTargets": [
"rak4631",
"rak_wismeshtag",
"t-echo",
"heltec-mesh-node-t114",
"nrf52_promicro_diy_tcxo",
"thinknode_m1",
"thinknode_m3",
"thinknode_m6",
"tracker-t1000-e",
"seeed_wio_tracker_L1",
"seeed_wio_tracker_L1_eink",
"seeed_solar_node",
"seeed_xiao_nrf52840_kit"
]
"7.3.0": {
"fileName": "nrf_erase_sd7_3.uf2",
"sha256": "13941bedce009e61255c37b1524d11ca604e88c38e7588bb8b391e2998da468f",
"expectedFirstTargetAddress": 159744
}
},
"nrf52Bootloader": {
"fileName": "meshtastic_factory_erase.uf2",
"sha256": "6ef3146505c40079ee9e7e692448e40a793dad636f55d1545063299d28908f0d",
"expectedFamilyId": 1296388936
},
"rp2040": {
"fileName": "pico_erase.uf2",
"sha256": "08aa7d561e8b8bf2f9b061b3506fb4d8f135e832efe0f3ae978241db2da0c853"
}
"""
.trimIndent(),
)
},
"otafixByBoardId": {
"HT-n5262": {
"otafixBoardSlug": "heltec_t114",
"sha256": "ae92d3577cb58dd9b43c9b61ffb9bfffda05b0eca4113a0ec42a37cd8be53b19"
},
"MinewSemi-MX25LE01": {
"otafixBoardSlug": "minewsemi_mx25le01",
"sha256": "e09564fd8dd03fc25d76dcb732a0214c79653da3b130240949b783254d3dfc1b"
},
"TRACKER L1": {
"otafixBoardSlug": "wio_tracker_l1",
"sha256": "70fbce0eda9d70d7bd8a4367057badf5ec310838bf3221370d45a56f04956b9e"
},
"WisBlock-RAK4631-Board": {
"otafixBoardSlug": "wiscore_rak4631_board",
"sha256": "8741bc677a3c24f28422c5ffb80761de7d98a127a3b0191ba6585bf57ce9f305"
},
"WisMesh-Tag": {
"otafixBoardSlug": "wismesh_tag",
"sha256": "96d42e1990e17251e8c625e98a1551cac12c6e29111bc2e59ab7c9fe6dec8758"
},
"nRF52840-SeeedSenseCAPSolarP1-v1": {
"otafixBoardSlug": "sensecap_solar_p1",
"sha256": "9b4bce48c1b4830617715c5619457bce6b21f3079803e35e13433de7701290f5"
},
"nRF52840-SeeedXiao-v1": {
"otafixBoardSlug": "xiao_nrf52840_ble",
"sha256": "ff8a0916e98cceb394fd66590bccc17f63612c11ff56b086ef88bd436c8df67f"
},
"nRF52840-SeeedXiaoSense-v1": {
"otafixBoardSlug": "xiao_nrf52840_ble_sense",
"sha256": "fc233d83a1011419625fcb50b49084578460c25bbc0270374ca176757a3c40da"
},
"nRF52840-T1000-E-v1": {
"otafixBoardSlug": "t1000_e",
"sha256": "5c065e11b8acd5b0cefa9295f98bca1512306cfa478856aa76a871124a904cc4"
},
"nRF52840-TEcho-v1": {
"otafixBoardSlug": "lilygo_techo",
"sha256": "2ddb36188ffe521c270bb2ce8441d742d0fe45325c57e4db6475bf63162a59b0"
},
"nRF52840-ThinkNode-M3-v1": {
"otafixBoardSlug": "thinknode_m3",
"sha256": "bf90979f2f6adc96ef6ca09c280b2ab7e66cb8ce2654fc80da9b20407bfb8708"
},
"nRF52840-ThinkNodeM1-v1": {
"otafixBoardSlug": "thinknode_m1",
"sha256": "aa0721b573c60e0b179274d5a5296bac7a8436faf339cfc03116ebe8a4375795"
},
"nRF52840-ThinkNodeM6-v1": {
"otafixBoardSlug": "thinknode_m6",
"sha256": "aaf94953a540a18f3e48f4cdec0c78290ad3c5f8740aea26fa3b3ce3632a8d4a"
},
"nRF52840-promicro": {
"otafixBoardSlug": "promicro_nrf52840",
"sha256": "46ef3440f151d6f2606075bcd1aa83db25a660da7d25b988aeb47ef350c98794"
}
},
"otafixSupportedTargets": [
"rak4631",
"rak_wismeshtag",
"t-echo",
"heltec-mesh-node-t114",
"nrf52_promicro_diy_tcxo",
"thinknode_m1",
"thinknode_m3",
"thinknode_m6",
"tracker-t1000-e",
"seeed_wio_tracker_L1",
"seeed_wio_tracker_L1_eink",
"seeed_solar_node",
"seeed_xiao_nrf52840_kit"
]
}
"""
.trimIndent(),
)
private val treeUri = CommonUri.parse("content://com.android.externalstorage.documents/tree/1234-5678%3A")
@@ -38,33 +38,34 @@ import kotlin.test.assertTrue
*/
abstract class CommonUsbPassWriterTest {
private val manifestJson = Json { ignoreUnknownKeys = true }
private val manifest =
Json { ignoreUnknownKeys = true }
.decodeFromString<MaintenanceUf2Manifest>(
"""
{
"manifestVersion": 1,
"otafixReleaseTag": "0.9.2-OTAFIX2.3-BP1.5",
"otafixBase": "https://example.invalid/otafix",
"erase": {
"nrf52": {
"6.1.1": { "fileName": "nrf_erase2.uf2", "sha256": "00", "expectedFirstTargetAddress": 155648 }
},
"nrf52Bootloader": {
"fileName": "meshtastic_factory_erase.uf2",
"sha256": "00",
"expectedFamilyId": 1296388936
},
"rp2040": { "fileName": "pico_erase.uf2", "sha256": "00" }
},
"otafixByBoardId": {
"WisBlock-RAK4631-Board": { "otafixBoardSlug": "wiscore_rak4631_board", "sha256": "00" }
},
"otafixSupportedTargets": ["rak4631"]
}
"""
.trimIndent(),
)
manifestJson.decodeFromString<MaintenanceUf2Manifest>(
"""
{
"manifestVersion": 1,
"otafixReleaseTag": "0.9.2-OTAFIX2.3-BP1.5",
"otafixBase": "https://example.invalid/otafix",
"erase": {
"nrf52": {
"6.1.1": { "fileName": "nrf_erase2.uf2", "sha256": "00", "expectedFirstTargetAddress": 155648 }
},
"nrf52Bootloader": {
"fileName": "meshtastic_factory_erase.uf2",
"sha256": "00",
"expectedFamilyId": 1296388936
},
"rp2040": { "fileName": "pico_erase.uf2", "sha256": "00" }
},
"otafixByBoardId": {
"WisBlock-RAK4631-Board": { "otafixBoardSlug": "wiscore_rak4631_board", "sha256": "00" }
},
"otafixSupportedTargets": ["rak4631"]
}
"""
.trimIndent(),
)
private val treeUri = CommonUri.parse("content://com.android.externalstorage.documents/tree/1234-5678%3A")
@@ -40,119 +40,120 @@ import kotlin.test.assertTrue
*/
class UsbMaintenanceGateTest {
private val manifestJson = Json { ignoreUnknownKeys = true }
/**
* The real maintenance-UF2 manifest ([api/data/maintenanceUf2.json] in `meshtastic/api`, embedded verbatim), so
* this test suite keeps exercising the exact board/digest table that ships, not a hand-trimmed fixture that could
* drift from it silently.
*/
private val testManifest =
Json { ignoreUnknownKeys = true }
.decodeFromString<MaintenanceUf2Manifest>(
"""
{
"manifestVersion": 1,
"otafixReleaseTag": "0.9.2-OTAFIX2.3-BP1.5",
"otafixBase": "https://github.com/meshtastic/Adafruit_nRF52_Bootloader_OTAFIX/releases/download/0.9.2-OTAFIX2.3-BP1.5",
"erase": {
"nrf52": {
"6.1.1": {
"fileName": "nrf_erase2.uf2",
"sha256": "4b778a3def19854415db64cb51bfd29c15b11cc46006353dd518f62d09efe3fe",
"expectedFirstTargetAddress": 155648
},
"7.3.0": {
"fileName": "nrf_erase_sd7_3.uf2",
"sha256": "13941bedce009e61255c37b1524d11ca604e88c38e7588bb8b391e2998da468f",
"expectedFirstTargetAddress": 159744
}
},
"nrf52Bootloader": {
"fileName": "meshtastic_factory_erase.uf2",
"sha256": "6ef3146505c40079ee9e7e692448e40a793dad636f55d1545063299d28908f0d",
"expectedFamilyId": 1296388936
},
"rp2040": {
"fileName": "pico_erase.uf2",
"sha256": "08aa7d561e8b8bf2f9b061b3506fb4d8f135e832efe0f3ae978241db2da0c853"
}
manifestJson.decodeFromString<MaintenanceUf2Manifest>(
"""
{
"manifestVersion": 1,
"otafixReleaseTag": "0.9.2-OTAFIX2.3-BP1.5",
"otafixBase": "https://github.com/meshtastic/Adafruit_nRF52_Bootloader_OTAFIX/releases/download/0.9.2-OTAFIX2.3-BP1.5",
"erase": {
"nrf52": {
"6.1.1": {
"fileName": "nrf_erase2.uf2",
"sha256": "4b778a3def19854415db64cb51bfd29c15b11cc46006353dd518f62d09efe3fe",
"expectedFirstTargetAddress": 155648
},
"otafixByBoardId": {
"HT-n5262": {
"otafixBoardSlug": "heltec_t114",
"sha256": "ae92d3577cb58dd9b43c9b61ffb9bfffda05b0eca4113a0ec42a37cd8be53b19"
},
"MinewSemi-MX25LE01": {
"otafixBoardSlug": "minewsemi_mx25le01",
"sha256": "e09564fd8dd03fc25d76dcb732a0214c79653da3b130240949b783254d3dfc1b"
},
"TRACKER L1": {
"otafixBoardSlug": "wio_tracker_l1",
"sha256": "70fbce0eda9d70d7bd8a4367057badf5ec310838bf3221370d45a56f04956b9e"
},
"WisBlock-RAK4631-Board": {
"otafixBoardSlug": "wiscore_rak4631_board",
"sha256": "8741bc677a3c24f28422c5ffb80761de7d98a127a3b0191ba6585bf57ce9f305"
},
"WisMesh-Tag": {
"otafixBoardSlug": "wismesh_tag",
"sha256": "96d42e1990e17251e8c625e98a1551cac12c6e29111bc2e59ab7c9fe6dec8758"
},
"nRF52840-SeeedSenseCAPSolarP1-v1": {
"otafixBoardSlug": "sensecap_solar_p1",
"sha256": "9b4bce48c1b4830617715c5619457bce6b21f3079803e35e13433de7701290f5"
},
"nRF52840-SeeedXiao-v1": {
"otafixBoardSlug": "xiao_nrf52840_ble",
"sha256": "ff8a0916e98cceb394fd66590bccc17f63612c11ff56b086ef88bd436c8df67f"
},
"nRF52840-SeeedXiaoSense-v1": {
"otafixBoardSlug": "xiao_nrf52840_ble_sense",
"sha256": "fc233d83a1011419625fcb50b49084578460c25bbc0270374ca176757a3c40da"
},
"nRF52840-T1000-E-v1": {
"otafixBoardSlug": "t1000_e",
"sha256": "5c065e11b8acd5b0cefa9295f98bca1512306cfa478856aa76a871124a904cc4"
},
"nRF52840-TEcho-v1": {
"otafixBoardSlug": "lilygo_techo",
"sha256": "2ddb36188ffe521c270bb2ce8441d742d0fe45325c57e4db6475bf63162a59b0"
},
"nRF52840-ThinkNode-M3-v1": {
"otafixBoardSlug": "thinknode_m3",
"sha256": "bf90979f2f6adc96ef6ca09c280b2ab7e66cb8ce2654fc80da9b20407bfb8708"
},
"nRF52840-ThinkNodeM1-v1": {
"otafixBoardSlug": "thinknode_m1",
"sha256": "aa0721b573c60e0b179274d5a5296bac7a8436faf339cfc03116ebe8a4375795"
},
"nRF52840-ThinkNodeM6-v1": {
"otafixBoardSlug": "thinknode_m6",
"sha256": "aaf94953a540a18f3e48f4cdec0c78290ad3c5f8740aea26fa3b3ce3632a8d4a"
},
"nRF52840-promicro": {
"otafixBoardSlug": "promicro_nrf52840",
"sha256": "46ef3440f151d6f2606075bcd1aa83db25a660da7d25b988aeb47ef350c98794"
}
},
"otafixSupportedTargets": [
"rak4631",
"rak_wismeshtag",
"t-echo",
"heltec-mesh-node-t114",
"nrf52_promicro_diy_tcxo",
"thinknode_m1",
"thinknode_m3",
"thinknode_m6",
"tracker-t1000-e",
"seeed_wio_tracker_L1",
"seeed_wio_tracker_L1_eink",
"seeed_solar_node",
"seeed_xiao_nrf52840_kit"
]
"7.3.0": {
"fileName": "nrf_erase_sd7_3.uf2",
"sha256": "13941bedce009e61255c37b1524d11ca604e88c38e7588bb8b391e2998da468f",
"expectedFirstTargetAddress": 159744
}
},
"nrf52Bootloader": {
"fileName": "meshtastic_factory_erase.uf2",
"sha256": "6ef3146505c40079ee9e7e692448e40a793dad636f55d1545063299d28908f0d",
"expectedFamilyId": 1296388936
},
"rp2040": {
"fileName": "pico_erase.uf2",
"sha256": "08aa7d561e8b8bf2f9b061b3506fb4d8f135e832efe0f3ae978241db2da0c853"
}
"""
.trimIndent(),
)
},
"otafixByBoardId": {
"HT-n5262": {
"otafixBoardSlug": "heltec_t114",
"sha256": "ae92d3577cb58dd9b43c9b61ffb9bfffda05b0eca4113a0ec42a37cd8be53b19"
},
"MinewSemi-MX25LE01": {
"otafixBoardSlug": "minewsemi_mx25le01",
"sha256": "e09564fd8dd03fc25d76dcb732a0214c79653da3b130240949b783254d3dfc1b"
},
"TRACKER L1": {
"otafixBoardSlug": "wio_tracker_l1",
"sha256": "70fbce0eda9d70d7bd8a4367057badf5ec310838bf3221370d45a56f04956b9e"
},
"WisBlock-RAK4631-Board": {
"otafixBoardSlug": "wiscore_rak4631_board",
"sha256": "8741bc677a3c24f28422c5ffb80761de7d98a127a3b0191ba6585bf57ce9f305"
},
"WisMesh-Tag": {
"otafixBoardSlug": "wismesh_tag",
"sha256": "96d42e1990e17251e8c625e98a1551cac12c6e29111bc2e59ab7c9fe6dec8758"
},
"nRF52840-SeeedSenseCAPSolarP1-v1": {
"otafixBoardSlug": "sensecap_solar_p1",
"sha256": "9b4bce48c1b4830617715c5619457bce6b21f3079803e35e13433de7701290f5"
},
"nRF52840-SeeedXiao-v1": {
"otafixBoardSlug": "xiao_nrf52840_ble",
"sha256": "ff8a0916e98cceb394fd66590bccc17f63612c11ff56b086ef88bd436c8df67f"
},
"nRF52840-SeeedXiaoSense-v1": {
"otafixBoardSlug": "xiao_nrf52840_ble_sense",
"sha256": "fc233d83a1011419625fcb50b49084578460c25bbc0270374ca176757a3c40da"
},
"nRF52840-T1000-E-v1": {
"otafixBoardSlug": "t1000_e",
"sha256": "5c065e11b8acd5b0cefa9295f98bca1512306cfa478856aa76a871124a904cc4"
},
"nRF52840-TEcho-v1": {
"otafixBoardSlug": "lilygo_techo",
"sha256": "2ddb36188ffe521c270bb2ce8441d742d0fe45325c57e4db6475bf63162a59b0"
},
"nRF52840-ThinkNode-M3-v1": {
"otafixBoardSlug": "thinknode_m3",
"sha256": "bf90979f2f6adc96ef6ca09c280b2ab7e66cb8ce2654fc80da9b20407bfb8708"
},
"nRF52840-ThinkNodeM1-v1": {
"otafixBoardSlug": "thinknode_m1",
"sha256": "aa0721b573c60e0b179274d5a5296bac7a8436faf339cfc03116ebe8a4375795"
},
"nRF52840-ThinkNodeM6-v1": {
"otafixBoardSlug": "thinknode_m6",
"sha256": "aaf94953a540a18f3e48f4cdec0c78290ad3c5f8740aea26fa3b3ce3632a8d4a"
},
"nRF52840-promicro": {
"otafixBoardSlug": "promicro_nrf52840",
"sha256": "46ef3440f151d6f2606075bcd1aa83db25a660da7d25b988aeb47ef350c98794"
}
},
"otafixSupportedTargets": [
"rak4631",
"rak_wismeshtag",
"t-echo",
"heltec-mesh-node-t114",
"nrf52_promicro_diy_tcxo",
"thinknode_m1",
"thinknode_m3",
"thinknode_m6",
"tracker-t1000-e",
"seeed_wio_tracker_L1",
"seeed_wio_tracker_L1_eink",
"seeed_solar_node",
"seeed_xiao_nrf52840_kit"
]
}
"""
.trimIndent(),
)
private fun nrf(
variant: SoftDeviceVariant? = SoftDeviceVariant.S140_6_1_1,
@@ -212,20 +213,19 @@ class UsbMaintenanceGateTest {
@Test
fun `an unsafe erase filename refuses the image instead of throwing`() {
val hostile =
Json { ignoreUnknownKeys = true }
.decodeFromString<MaintenanceUf2Manifest>(
"""
{
"erase": {
"nrf52": {
"6.1.1": { "fileName": "../../etc/passwd", "sha256": "00" }
},
"rp2040": { "fileName": "sub/dir/pico_erase.uf2", "sha256": "00" }
}
}
"""
.trimIndent(),
)
manifestJson.decodeFromString<MaintenanceUf2Manifest>(
"""
{
"erase": {
"nrf52": {
"6.1.1": { "fileName": "../../etc/passwd", "sha256": "00" }
},
"rp2040": { "fileName": "sub/dir/pico_erase.uf2", "sha256": "00" }
}
}
"""
.trimIndent(),
)
assertNull(eraseUf2For(hostile, nrf()), "A traversal fileName must resolve to null, not throw")
assertNull(eraseUf2For(hostile, rp2040()), "A separator in fileName must resolve to null, not throw")
@@ -238,19 +238,18 @@ class UsbMaintenanceGateTest {
@Test
fun `an unsafe otafix slug or tag refuses the image instead of throwing`() {
val hostile =
Json { ignoreUnknownKeys = true }
.decodeFromString<MaintenanceUf2Manifest>(
"""
{
"otafixReleaseTag": "../../../evil",
"otafixBase": "https://example.invalid/releases",
"otafixByBoardId": {
"rak4631": { "otafixBoardSlug": "rak4631", "sha256": "00" }
}
}
"""
.trimIndent(),
)
manifestJson.decodeFromString<MaintenanceUf2Manifest>(
"""
{
"otafixReleaseTag": "../../../evil",
"otafixBase": "https://example.invalid/releases",
"otafixByBoardId": {
"rak4631": { "otafixBoardSlug": "rak4631", "sha256": "00" }
}
}
"""
.trimIndent(),
)
assertNull(otafixUf2ForBoardId(hostile, "rak4631"), "A traversal release tag must resolve to null, not throw")
}
@@ -374,7 +374,6 @@ class Esp32OtaUpdateHandlerTest {
}
assertTrue(events.none { it.startsWith("start:") || it.startsWith("stream:") })
assertIs<FirmwareUpdateState.Error>(states.lastOrNull())
Unit
}
}
@@ -408,7 +407,6 @@ class Esp32OtaUpdateHandlerTest {
}
assertTrue(events.none { it.startsWith("start:") || it.startsWith("stream:") })
assertIs<FirmwareUpdateState.Error>(states.lastOrNull())
Unit
}
}
@@ -23,6 +23,7 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation
@@ -429,26 +430,28 @@ class LegacyDfuTransportTest {
*/
var throwExceptionOnControlPointWrites: Boolean = false
override fun hasCharacteristic(c: BleCharacteristic) = delegate.hasCharacteristic(c)
override fun hasCharacteristic(characteristic: BleCharacteristic) = delegate.hasCharacteristic(characteristic)
override fun observe(c: BleCharacteristic): Flow<ByteArray> = delegate.observe(c)
override fun observe(characteristic: BleCharacteristic): Flow<ByteArray> = delegate.observe(characteristic)
override suspend fun read(c: BleCharacteristic): ByteArray = delegate.read(c)
override suspend fun read(characteristic: BleCharacteristic): ByteArray = delegate.read(characteristic)
override fun preferredWriteType(c: BleCharacteristic): BleWriteType = delegate.preferredWriteType(c)
override fun preferredWriteType(characteristic: BleCharacteristic): BleWriteType =
delegate.preferredWriteType(characteristic)
override suspend fun write(c: BleCharacteristic, data: ByteArray, writeType: BleWriteType) {
if (throwErrorOnControlPointWrites && c.uuid == LegacyDfuUuids.CONTROL_POINT) {
override suspend fun write(characteristic: BleCharacteristic, data: ByteArray, writeType: BleWriteType) {
val isControlPoint = characteristic.uuid == LegacyDfuUuids.CONTROL_POINT
if (throwErrorOnControlPointWrites && isControlPoint) {
throw AssertionError("Simulated assertion failure during control point write")
}
if (throwExceptionOnControlPointWrites && c.uuid == LegacyDfuUuids.CONTROL_POINT) {
if (throwExceptionOnControlPointWrites && isControlPoint) {
throw RuntimeException("Simulated link failure during control point write")
}
if (hangOnControlPointWrites && c.uuid == LegacyDfuUuids.CONTROL_POINT) {
if (hangOnControlPointWrites && isControlPoint) {
awaitCancellation()
}
delegate.write(c, data, writeType)
val response = responder.onWrite(c.uuid, data) ?: return
delegate.write(characteristic, data, writeType)
val response = responder.onWrite(characteristic.uuid, data) ?: return
response.forEach { delegate.emitNotification(LegacyDfuUuids.CONTROL_POINT, it) }
}
}
@@ -644,6 +647,7 @@ class LegacyDfuTransportTest {
* where the write throws before the watcher processes the emission), while `bleConnection.connectionState.value`
* still reads as Disconnected for the write-catch classification.
*/
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
private class DisconnectEmissionsSuppressedStateFlow(private val delegate: StateFlow<BleConnectionState>) :
StateFlow<BleConnectionState> {
override val value: BleConnectionState
@@ -971,7 +975,7 @@ class LegacyDfuTransportTest {
// parentJob stands in for the caller's scope; cancelling it must propagate through withTimeoutOrNull
// (which only swallows its own TimeoutCancellationException, not parent cancellation) and out of abort.
val parentJob = Job()
val abortDeferred = async(parentJob) { env.transport.abort() }
val abortDeferred = CoroutineScope(coroutineContext + parentJob).async { env.transport.abort() }
// Let abort reach the hanging RESET write.
runCurrent()
@@ -21,12 +21,12 @@ import ch.poole.geo.pmtiles.HttpUrlConnectionChannel
import ch.poole.geo.pmtiles.Reader
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.net.URL
import java.net.URI
import java.util.zip.GZIPInputStream
actual class TerrainTileFetcher actual constructor(pmtilesUrl: String) : AutoCloseable {
private val reader: Reader = Reader(HttpUrlConnectionChannel(URL(pmtilesUrl)))
private val reader: Reader = Reader(HttpUrlConnectionChannel(URI(pmtilesUrl).toURL()))
actual fun fetchTile(zoom: Int, x: Int, y: Int): ByteArray? {
val raw = reader.getTile(zoom, x, y) ?: return null
@@ -21,12 +21,12 @@ import ch.poole.geo.pmtiles.HttpUrlConnectionChannel
import ch.poole.geo.pmtiles.Reader
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.net.URL
import java.net.URI
import java.util.zip.GZIPInputStream
actual class TerrainTileFetcher actual constructor(pmtilesUrl: String) : AutoCloseable {
private val reader: Reader = Reader(HttpUrlConnectionChannel(URL(pmtilesUrl)))
private val reader: Reader = Reader(HttpUrlConnectionChannel(URI(pmtilesUrl).toURL()))
actual fun fetchTile(zoom: Int, x: Int, y: Int): ByteArray? {
val raw = reader.getTile(zoom, x, y) ?: return null
-1
View File
@@ -28,7 +28,6 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.collections.immutable)
// KML import parses through the same xmlutil the app already resolves for CoT XML.
implementation(libs.xmlutil.core)
implementation(projects.core.data)
@@ -65,7 +65,7 @@ class KmlGroundOverlayTest {
}
@Test
fun `an overlay-only document is not "nothing mappable"`() {
fun `an overlay-only document still counts as mappable`() {
val result =
KmlToGeoJson.convertDocument(
"""
+1
View File
@@ -18,6 +18,7 @@
plugins { alias(libs.plugins.meshtastic.kmp.feature) }
kotlin {
// No withHostTest: commonTest holds Compose UI tests, which NPE on the host-test stubs' null Build.FINGERPRINT.
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
+1
View File
@@ -22,6 +22,7 @@ plugins {
}
kotlin {
// No withHostTest: commonTest holds Compose UI tests, which NPE on the host-test stubs' null Build.FINGERPRINT.
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
@@ -1125,7 +1125,7 @@ class RadioConfigViewModelTest {
}
every { processRadioResponseUseCase(any(), 123, any()) } calls
{
val pendingRequestIds = it.args[2] as Set<Int>
val pendingRequestIds = it.arg<Set<Int>>(2)
if (42 in pendingRequestIds) RadioResponseResult.Owner(owner) else null
}
@@ -1287,8 +1287,8 @@ class RadioConfigViewModelTest {
// Channel A (index 1) completed before channel B (index 2) threw.
assertEquals(listOf(1, 2), writtenIndexes)
assertNotNull(interrupted)
assertEquals(1, interrupted!!.appliedWriteCount)
assertEquals("A", interrupted!!.appliedSettings[1].name)
assertEquals(1, interrupted.appliedWriteCount)
assertEquals("A", interrupted.appliedSettings[1].name)
}
@Test
@@ -1938,7 +1938,7 @@ class RadioConfigViewModelTest {
var response: RadioResponseResult = RadioResponseResult.Error(maxRetransmit, Routing.Error.MAX_RETRANSMIT)
every { processRadioResponseUseCase(any(), 456, any()) } calls
{
val pendingRequestIds = it.args[2] as Set<Int>
val pendingRequestIds = it.arg<Set<Int>>(2)
if (42 in pendingRequestIds) response else null
}
nodeRepository.setNodes(listOf(localNode, remoteNode))
+3 -3
View File
@@ -1,6 +1,4 @@
# --- Android build features ---
android.defaults.buildfeatures.resvalues=false
android.defaults.buildfeatures.shaders=false
# --- Android ---
# Lint analysis runs in its own worker, so neither org.gradle.jvmargs nor
# kotlin.daemon.jvmargs applies to it. The default heap OOMs on a cold analysis.
android.experimental.lint.heapSize=3G
@@ -20,6 +18,8 @@ kotlin.daemon.useFallback=false
# The Kotlin/Native compiler runs in its own JVM, governed by neither of the heaps
# above. Its 3g default OOMs compiling every module for iOS from a cold cache.
kotlin.native.jvmArgs=-Xmx5g -XX\:+UseG1GC -XX\:+HeapDumpOnOutOfMemoryError
# Only macOS can run iosSimulatorArm64Test; this hides KGP's notice about it on Linux and Windows hosts.
kotlin.native.ignoreDisabledTargets=true
# --- KSP ---
# (incremental processing is default-on in KSP2; only the non-default flag is declared)
+3 -9
View File
@@ -34,7 +34,7 @@ ktfmt = "0.61"
kover = "0.9.10"
mokkery = "3.5.0"
junit5 = "6.1.3"
junit-platform = "6.1.3" # aligned with junit5 — JUnit Platform uses 1.x scheme
junit-platform = "6.1.3" # JUnit Platform shares the junit5 version since JUnit 6
kotest = "6.2.5"
konsist = "0.17.3"
turbine = "1.2.1"
@@ -44,8 +44,7 @@ compose-screenshot = "0.0.1-alpha16"
# Baseline Profiles / Macrobenchmark
# `benchmark` drives both the androidx.benchmark macro lib AND the androidx.baselineprofile
# Gradle plugin (same coordinates/version). Kept on the alpha track to stay compatible with
# AGP 9.x (the stable 1.4.x line predates AGP 9 support).
# Gradle plugin (same coordinates/version). The stable 1.4.x line predates AGP 9 support.
benchmark = "1.5.0"
profileinstaller = "1.4.1"
androidx-uiautomator = "2.4.0"
@@ -113,7 +112,6 @@ meshtastic-protobufs = "2.8.0.123-gadda936-SNAPSHOT"
# Gradle Plugins
ccud = "2.8.0"
develocity = "4.6.0"
foojay-resolver = "1.0.0"
[libraries]
@@ -381,7 +379,6 @@ dd-sdk-android = [
# Android
android-application = { id = "com.android.application", version.ref = "agp" }
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
android-test = { id = "com.android.test" }
androidx-baselineprofile = { id = "androidx.baselineprofile", version.ref = "benchmark" }
compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "compose-screenshot" }
@@ -410,9 +407,6 @@ dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
room = { id = "androidx.room3", version.ref = "room" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
develocity = { id = "com.gradle.develocity", version.ref = "develocity" }
foojay-resolver = { id = "org.gradle.toolchains.foojay-resolver", version.ref = "foojay-resolver" }
# Meshtastic
meshtastic-android-application = { id = "meshtastic.android.application" }
meshtastic-android-application-compose = { id = "meshtastic.android.application.compose" }
@@ -424,6 +418,7 @@ meshtastic-android-room = { id = "meshtastic.android.room" }
meshtastic-android-screenshot = { id = "meshtastic.android.screenshot" }
# Vendored google/secrets-gradle-plugin (Isolated-Projects-safe) — see build-logic/convention.
meshtastic-android-secrets = { id = "meshtastic.android.secrets" }
meshtastic-android-test = { id = "meshtastic.android.test" }
meshtastic-detekt = { id = "meshtastic.detekt" }
meshtastic-docs = { id = "meshtastic.docs" }
meshtastic-koin = { id = "meshtastic.koin" }
@@ -435,5 +430,4 @@ meshtastic-kmp-library = { id = "meshtastic.kmp.library" }
meshtastic-kmp-library-compose = { id = "meshtastic.kmp.library.compose" }
meshtastic-root = { id = "meshtastic.root" }
meshtastic-spotless = { id = "meshtastic.spotless" }
meshtastic-publishing = { id = "meshtastic.publishing" }
meshtastic-aboutlibraries = { id = "meshtastic.aboutlibraries" }
+4 -5
View File
@@ -2,11 +2,10 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
# -bin: nothing here reads -all's docs/sources. Update sha256 with the URL on upgrades
# (official .sha256 sits next to the distribution; renovate does both).
# On 9.7.1 again after the #6786 pin-back. Both of that revert's blockers must stay dead
# on any future 9.7+ bump: the CC fingerprint crashing on reload of its own same-key entry
# (IsInIdeaSyncValueSource CNFE in the settings-plugin scope), and CMP's Windows packaging
# tripping an Isolated Projects violation (':desktopApp' cannot access Project.layout on
# ':'). The first only shows on a CC cache-key HIT, so one green run does not clear it.
# Every bump must keep two failures dead: the CC fingerprint crashing on reload of its own
# same-key entry (IsInIdeaSyncValueSource CNFE in the settings-plugin scope), and CMP's Windows
# packaging tripping an Isolated Projects violation (':desktopApp' cannot access Project.layout
# on ':'). The first only shows on a CC cache-key HIT, so one green run does not clear it.
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=30000
+5 -1
View File
@@ -15,6 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.meshtastic.buildlogic.kotlinWarningsAsErrors
// Writes core/resources/.../values/schema_strings.xml: every label and description in the protobufs field-metadata
// registry, keyed by schema path (`schema_lora_hop_limit`). A settings control that edits a schema field names that
@@ -31,7 +32,10 @@ kotlin {
languageVersion.set(JavaLanguageVersion.of(25))
vendor.set(JvmVendorSpec.JETBRAINS)
}
compilerOptions { jvmTarget.set(JvmTarget.JVM_25) }
compilerOptions {
jvmTarget.set(JvmTarget.JVM_25)
allWarningsAsErrors.set(kotlinWarningsAsErrors)
}
}
dependencies {
+2 -15
View File
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
// Captures the store-listing screenshots from the real debug app on a device or emulator:
// ./gradlew :store-screenshots:connectedGoogleDebugAndroidTest (Play)
@@ -24,25 +23,15 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
// adb pull /data/local/tmp/store-screenshots/<flavor>/. <dir>
// .github/workflows/store-screenshots.yml does this for both flavors on one emulator.
plugins {
alias(libs.plugins.android.test)
alias(libs.plugins.meshtastic.android.test)
alias(libs.plugins.meshtastic.detekt)
alias(libs.plugins.meshtastic.spotless)
}
android {
namespace = "org.meshtastic.storescreenshots"
compileSdk = 37
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
defaultConfig {
minSdk = 28
targetSdk = 37
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
defaultConfig { minSdk = 28 }
targetProjectPath = ":androidApp"
// Its own process, so relaunching the app between form factors does not end the run.
@@ -68,8 +57,6 @@ tasks
.named { it == "connectedFdroidDebugAndroidTest" }
.configureEach { mustRunAfter("connectedGoogleDebugAndroidTest") }
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
dependencies {
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.test.runner)