Review a Meshtastic-Android change against KMP architecture, Compose Multiplatform and Modern Android Development conventions, starting from the four defect classes that keep recurring because neither the compiler, detekt nor spotless can see them. Use this for any PR review, for self-review before pushing, and whenever asked whether a change is safe to merge.
67
81%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Perform comprehensive code reviews for Meshtastic-Android, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices.
These four classes account for most of the Major findings raised on recent PRs, and they recur because the compiler, detekt, and spotless cannot see any of them. Check them while writing the code, not only while reviewing it.
A numeric field whose absence matters must be nullable. Never let 0 stand in for "not reported".
0 is a legitimate reading for RSSI (0 dBm), SNR, temperature, and air-quality concentration, so a 0 default silently merges "no data" with a real measurement. Both directions are bugs: an absent value gets persisted and displayed as a real one, and a genuine 0 gets discarded by a takeIf { it != 0 } guard.
null, not 0. Absence checks read == null and presence checks != null — never == 0 for either.T? and propagate null for an empty input. A 0 fallback biases the result in whichever direction the comparator sorts — under the higher-is-better RSSI ordering used for ranking, an empty set's 0 outranks a real -80 dBm; under a plain min it would instead win as the smallest. Either way the missing value competes as if measured.takeIf { it != 0 } is only valid where 0 is genuinely impossible. On any signed or zero-inclusive scale it destroys data.0 comparison. Fields with no presence cannot be fixed app-side — say so rather than faking it.Read the current value, decide, and write inside a single dataStore.edit { } block.
Reading a StateFlow (or a prior suspend getter), branching on it, and then issuing separate writes leaves a window where a concurrent change interleaves — so a guard can fire against state that no longer exists and clobber a user preference. NotificationPrefsImpl.setGeofenceAlertOptIn (core/prefs/…/notification/NotificationPrefsImpl.kt) is the reference example: it parses, mutates, caps, and writes in one edit.
edit/updateData lambda.prefs/current snapshot, not a cached StateFlow value from outside.edit call, not several scope.launch writes.editSettings { } transaction (see AdminController.editSettings).Assert that the intended code path produced the result — not merely that the result exists.
Seeding a fake's backing store and then asserting the value comes back passes even if the production code under test is deleted. The test must fail when the path breaks.
Dispatchers.Unconfined: emission order is not a stable contract there — assert final state.Every schema-version increment ships a migration test that proves existing rows survive.
core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/<n>.json is accompanied by an (n-1)→n case in core/database/src/jvmTest/.../MeshtasticDatabaseMigrationTest.kt — the androidHostTest *MigrationTest.kt files cover DAO behaviour, not schema versions.NULL state is reachable (this is class A at the storage layer).When reviewing code, meticulously verify the following categories. Flag any deviations and propose the canonical project pattern as a fix.
java.* or android.* imports exist in commonMain source sets.java.util.concurrent.locks.* -> kotlinx.coroutines.sync.Mutexjava.util.concurrent.ConcurrentHashMap -> atomicfu or Mutex-guarded mutableMapOf()java.io.* -> Okio (BufferedSource/BufferedSink)java.util.Locale -> Kotlin uppercase()/lowercase() (purged from commonMain)safeCatching {} from core:common instead of runCatching {} in coroutine/suspend contexts. runCatching silently swallows CancellationException, breaking structured concurrency. Keep runCatching only in cleanup/teardown code (abort, close, eviction). Use kotlinx.coroutines.CancellationException (not kotlin.coroutines.cancellation.CancellationException).androidMain and jvmMain contain identical pure-Kotlin logic, mandate extracting it to a shared function in commonMain.expect/actual declarations, ensure files sharing the same package namespace have distinct names (e.g., keep expect in LogExporter.kt and shared helpers in LogFormatter.kt) to avoid duplicate class errors on the JVM target.expect/actual: Check that expect/actual is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities.core:resources (e.g., stringResource(Res.string.key) or asynchronous getStringSuspend(Res.string.key) for ViewModels/Coroutines). NEVER use blocking getString() in a coroutine.%N$s and %N$d. Flag any float formats (%N$.1f) in Compose string resources; they must be pre-formatted using NumberFormatter.format() from core:common. Use MetricFormatter for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI).AlertHost(alertManager) or SharedDialogs from core:ui/commonMain.PlaceholderScreen(name) from core:ui/commonMain for unimplemented desktopApp/JVM features. No inline placeholders in feature modules.currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true) to support desktopApp/tablet breakpoints (≥ 1200dp).EntryProviderScope<NavKey> in commonMain (e.g., fun EntryProviderScope<NavKey>.settingsGraph(...)). Flag any graphs defined in platform-specific source sets.MeshtasticNavDisplay (from core:ui/commonMain) is used as the host instead of invoking NavDisplay directly. Host modules should not configure entryDecorators themselves.koinViewModel() must be inside entry<T> blocks to correctly tie to the backstack lifetime.@Single, @Factory, @KoinViewModel).androidApp and desktopApp).HttpClientDefaults from core:network. Never hardcode timeouts in feature modules. DefaultRequest sets the base URL; feature API services use relative paths.coil-network-ktor3 in host modules. Feature modules should ONLY depend on libs.coil (coil-compose) and never configure fetchers.factory = { MeshtasticDatabaseConstructor.initialize() } is used in Room.databaseBuilder. DAOs and Entities must reside in commonMain.@Upsert for insert-or-update logic. Check for LIMIT 1 on single-row queries. Flag N+1 query patterns (loops calling single-row queries) — batch with chunked WHERE IN instead.core:ble using Kable abstractions.commonMain: Must use jetbrains-* aliases (e.g., jetbrains-lifecycle-*, jetbrains-navigation3-ui).androidMain: Can use androidx-* or jetbrains-* as appropriate, but do not mix them up in commonMain.compose-multiplatform-* aliases are used instead of plain androidx.compose in all KMP modules.commonTest using runComposeUiTest {} from androidx.compose.ui.test.v2 (not the deprecated v1 androidx.compose.ui.test package) + kotlin.test.Test. Do not add androidTest (instrumented) tests.core:testing.Turbine for Flow testing, Kotest for property-based testing, and Mokkery for mocking.@Config(sdk = [34]) to prevent SDK 35 compatibility issues.Kermit is the only logging API, and on the google flavor its writers fan every call out to both Firebase Crashlytics and Datadog RUM (androidApp/src/google/.../GooglePlatformAnalytics.kt). Log level is therefore a reporting decision, not just a verbosity one.
The rule: Logger.e means "a defect someone can fix". Everything else is Logger.w or below.
Severity.Error/Assert become a Crashlytics non-fatal (shouldReportAsException, which exempts CancellationException and any ExpectedCondition in the cause chain) and a Datadog RUM error (shouldDowngradeForDatadog, which exempts only ExpectedCondition). Warn and below never report in either sink, with no exceptions. Attaching a throwable at warn level is free and keeps the stack trace in the logs, so demoting costs nothing.CancellationException because it is a crash-triage tool; Datadog keeps it because a cancellation logged at error means a call site swallowed it instead of rethrowing — broken structured concurrency, and a real bug. That asymmetry is the detector that found #6468. Likewise, neither rule unwraps the cause chain for cancellation: coroutine machinery attaches cancellations as the cause of unrelated genuine failures, and unwrapping would silently drop those reports.Logger.e with no throwable still reports. Crashlytics synthesises an Exception(message); Datadog raises a RUM error from the level alone. Logger.e { "…" } is not a cheap log line.ExpectedCondition seam (core/common/src/commonMain/.../log/ExpectedCondition.kt):
ExpectedCondition and give it a stable, low-cardinality expectedConditionLabel (e.g. ble-scan-bluetooth-disabled). BleScanStartException is the reference example.Logger.w.shouldReportAsException(severity, throwable), so an ExpectedCondition is suppressed even if some call site logs it at error. Treat that as a backstop, not a licence to log expected states at error.core/ble/.../KermitLogEngine.kt (Kable).Logger.e in a PR: ask what the on-call engineer would do about it. If the answer is "nothing, that's just the user's phone", it is a Logger.w.androidApp/proguard-rules.pro (R8) and desktopApp/proguard-rules.pro (ProGuard). The two files must stay aligned.assembleRelease and ./gradlew :desktopApp:runRelease succeed.Problems only. Every comment identifies a concrete defect with evidence in the diff. No praise, no style preferences the linters already own, no speculative design feedback, no refactoring suggestions for code the PR did not touch. A review that finds nothing says so in one line.
AGENTS.md and the architecture playbooks to justify a change request (e.g., "Per AGENTS.md, java.io.* cannot be used in commonMain; please migrate to Okio")../gradlew test, say so: that task is ambiguous in KMP modules and silently skips them, so the code was never exercised and allTests is required. Do not append a generic build reminder to a review that has no such gap."There are tests" is not coverage. For each non-trivial production change, map: changed behaviour (the concrete code path) → observable surfaces (public API, protocol handling, persisted rows, DataStore, Compose state, notifications, service lifecycle, transport, MQTT, widgets, desktop, R8-shaped release behaviour) → regression risks (ordering, reconnect/retry, process death, schema compatibility with rows an older build wrote, cross-module call sites, flavor and platform differences) → the test that should exist and does not.
A bug fix needs a test that fails without the fix. An updated screenshot golden, Room schema JSON, or regenerated baseline profile proves serialisation, not behaviour. Don't demand a test category for a surface the change cannot reach.
When a type moves files or is extracted, diff the old implementation against the new one: a removed override, a changed exception contract, a dropped require/check, a changed default parameter value, a nullability flip on a numeric field (class A), a lost @Serializable/@Parcelize/Koin annotation, a scope change altering instance lifetime, a changed dispatcher or SharingStarted. Then verify every call site of the removed declaration still holds. Pre-existing defects that came along with the move are in scope — label them "pre-existing — good opportunity to fix during this refactor" so the author can decide on scope.
meshtastic/firmware repo PRs for tone and style.feat(scope):, fix(scope):, refactor(scope):, chore(scope):. Keep titles under ~72 characters.d003a21
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.