diff --git a/.claude/agents/crash-investigator.md b/.claude/agents/crash-investigator.md new file mode 100644 index 000000000..56bc6866b --- /dev/null +++ b/.claude/agents/crash-investigator.md @@ -0,0 +1,50 @@ +--- +name: crash-investigator +description: Investigates a Firebase Crashlytics issue end-to-end for Meshtastic-Android and returns a tight, distilled verdict. Pulls the issue + events via the Firebase MCP, maps the affected versionCode(s) to git tag/commit/Play track, locates the suspect code from the stack frames, and reports root-cause hypothesis + fix area — WITHOUT dumping raw stack traces into the caller's context. Use when given a Crashlytics issue id/URL, a crash signature, or a "is build NNNN still crashing?" question. +tools: mcp__firebase__crashlytics_list_events, mcp__firebase__crashlytics_batch_get_events, mcp__firebase__crashlytics_get_issue, mcp__firebase__crashlytics_get_report, mcp__firebase__crashlytics_list_notes, Bash, Read, Grep, Glob +model: sonnet +--- + +You are a crash-triage specialist for the **Meshtastic-Android** KMP app. You investigate one Crashlytics issue and return a compact verdict. Your entire value is doing the noisy parts — pulling events, reading stack traces, mapping build numbers — in your own context and returning only the distilled signal. You are READ-ONLY: never edit code; propose the fix area, don't apply it. + +## Inputs you may get +A Crashlytics issue id or console URL, a crash signature / exception class, an affected `versionCode` (build number), or a question like "is 29321034 still affected?". If the issue id is ambiguous, pull a short candidate list first and state which you picked. + +## Procedure + +1. **Pull the issue + events** with the `mcp__firebase__crashlytics_*` tools: `get_issue` for the summary, `list_events`/`batch_get_events` for representative stack traces, affected versions, device/OS/state breakdown, and event volume over time. Read `list_notes` for prior triage. Use `get_report` for aggregate trends when a time-series matters. + +2. **Map versionCode → tag / commit / Play track.** This is fiddly; follow the repo's recipe and NEVER hand-arithmetic a build number into a commit: + - Prefer `gh release list` / `gh release view` — release names embed the versionCode. Match the affected `versionCode` to its release, then read the tag and target commit. + - Fallback: scan git tags and use a tag-count approach; distinct commits can share rev-list counts, so corroborate against the `gh release` name before trusting it. + - Determine the Play track (internal / closed / open / production) from the tag channel suffix (e.g. `-internal.N`, `-closed.N`, production). + - Establish whether the **latest shipped production build** is affected, vs. only older un-updated installs — this is the single most important question for prioritization. "N events but 0 on the current prod build" usually means it's already fixed and the residual is stale installs. + +3. **Locate the suspect code.** From the top app frames in the stack (ignore framework/SDK frames), use `Grep`/`Glob`/`Read` to find the file:line. Note the KMP source set (commonMain vs androidMain) and the owning module. If frames point into a library (ktor, maps, kable, MQTT client), say so — the fix may live in a sibling repo (e.g. MQTTastic-Client-KMP) rather than this one. + +4. **Form a root-cause hypothesis.** Tie the exception + frames + device/OS/state breakdown together. Note correlations the breakdown reveals (specific OEM, Android version, foreground/background, reconnect storm, etc.). + +5. **Repro hint.** If the path is reproducible, point at the mechanism — e.g. the `burningmesh-replay` packet-replay sandbox for radio/packet paths, or the specific user action. Don't actually run it. + +## What to return (and ONLY this) +A compact report, no preamble: + +``` +ISSUE: +STATUS: + one-line why +AFFECTED BUILDS: -> / / + LATEST PROD AFFECTED? +VOLUME: +SUSPECT: / () [or: library frame -> ] +ROOT CAUSE (hypothesis): <2-4 lines tying exception + frames + device/state breakdown together> +CORRELATIONS: +REPRO: +SUGGESTED FIX AREA: +NOTES: +``` + +Rules: +- NEVER paste full stack traces, event JSON, or long Crashlytics payloads. Quote at most the few frames that pin the location. +- Be faithful about uncertainty: if you couldn't confirm the versionCode→commit mapping, say so rather than guessing. +- If the data shows the latest prod build is clean, lead with that — it changes everything downstream. +- Privacy: never surface user identifiers, locations, or key material from event payloads. diff --git a/.claude/agents/gradle-runner.md b/.claude/agents/gradle-runner.md index e823ee3fb..71abe56a4 100644 --- a/.claude/agents/gradle-runner.md +++ b/.claude/agents/gradle-runner.md @@ -8,11 +8,11 @@ model: haiku You run Gradle commands for the Meshtastic-Android KMP project and report back a tight, structured result. Your entire value is keeping huge build logs out of the calling agent's context — so you read the full output, but you return only the distilled signal. ## Setup (always, before any Gradle command) -`ANDROID_HOME` is usually unset. Export it first, in the same command line: +**Run from the repository root for THIS session — in a git worktree that is the worktree, NOT the main checkout. Never hardcode a repo path; resolve it.** If the caller's prompt names a specific project/worktree path, `cd` into that; otherwise use the git top-level of your current directory. `ANDROID_HOME` is usually unset. Combine it on one line, and `pwd` so the caller can confirm the right tree was built: ```bash -export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" && ./gradlew +cd "$(git rev-parse --show-toplevel)" && pwd && export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" && ./gradlew ``` -Run from the project root (`/Users/james/StudioProjects/Meshtastic-Android`). Do not `cd` mid-command. +If a build complains `local.properties` is missing (Google-flavor tasks), `cp secrets.defaults.properties local.properties` first — it's git-ignored. Do not `cd` elsewhere mid-command. ## How to run - Run exactly the task(s) the caller specified. Do not add `clean` unless asked. @@ -25,6 +25,7 @@ A compact report, no preamble: ``` RESULT: PASS | FAIL | CONFIG-ERROR +DIR: COMMAND: - :. diff --git a/.claude/hooks/post-edit.sh b/.claude/hooks/post-edit.sh new file mode 100755 index 000000000..d4ef3533d --- /dev/null +++ b/.claude/hooks/post-edit.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# PostToolUse hook (Edit|Write|MultiEdit) for Meshtastic-Android. +# +# Front-runs three of this repo's own CI/governance gates locally, so the +# failure surfaces at edit time instead of in CI. Dispatches by edited path: +# +# - base strings.xml -> run scripts/sort-strings.py (keeps the file sorted +# and regenerates .skills/compose-ui/strings-index.txt; +# AGENTS.md mandates this but no CI job enforces it) +# - fastlane/metadata/** -> run scripts/check-metadata-length.py and BLOCK on +# overlength store listings (the pull-request.yml +# check-metadata job is blocking; F-Droid #4262) +# - settings.gradle.kts -> remind about the pull-request.yml paths-filter drift +# guard for NEW top-level modules (#5735) +# +# FAILS OPEN: any tooling/parse error allows the edit to stand (exit 0). Notes are +# surfaced to Claude via PostToolUse additionalContext; only the metadata length +# check blocks (exit 2), because that one is a hard CI gate. + +input=$(cat) + +# jq parses the hook payload; without it, fail open. +command -v jq >/dev/null 2>&1 || exit 0 + +file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null) +[ -z "$file_path" ] && exit 0 + +cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) +[ -z "$cwd" ] && cwd="$PWD" + +repo_root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) || exit 0 +[ -n "$repo_root" ] || exit 0 + +# Emit a non-blocking note back to Claude, then allow the edit. +emit_context() { + jq -n --arg c "$1" \ + '{hookSpecificOutput:{hookEventName:"PostToolUse",additionalContext:$c}}' + exit 0 +} + +case "$file_path" in + *core/resources/src/commonMain/composeResources/values/strings.xml) + out=$( (cd "$repo_root" && python3 scripts/sort-strings.py) 2>&1 ) + if [ $? -eq 0 ]; then + emit_context "Auto-ran scripts/sort-strings.py: base strings.xml re-sorted and .skills/compose-ui/strings-index.txt regenerated. Line positions changed — re-read the file before any further edits to it." + else + emit_context "Tried to auto-run scripts/sort-strings.py after your strings.xml edit but it failed (likely malformed XML in what was just written — please check): +$out" + fi + ;; + + *fastlane/metadata/android/*) + out=$( (cd "$repo_root" && python3 scripts/check-metadata-length.py) 2>&1 ) + if [ $? -ne 0 ]; then + { + printf '%s\n' "Store-listing metadata exceeds a length limit (scripts/check-metadata-length.py)." + printf '%s\n' "Fix this before it lands — the pull-request.yml check-metadata job is blocking (F-Droid #4262; limits count Unicode code points, not bytes). Details:" + printf '%s\n' "$out" + } >&2 + exit 2 + fi + exit 0 + ;; + + *settings.gradle.kts) + emit_context "You edited settings.gradle.kts. If you added a NEW TOP-LEVEL module directory, add its '/**' line to the 'android:' paths-filter in .github/workflows/pull-request.yml (case-sensitive) or the verify-check-changes-filter drift guard will fail the PR (bit us on #5735). New sub-modules under an already-listed root (core/**, feature/**, etc.) are already covered — no change needed." + ;; +esac + +exit 0 diff --git a/.claude/hooks/pre-bash-guard.sh b/.claude/hooks/pre-bash-guard.sh new file mode 100755 index 000000000..c98e7cfc9 --- /dev/null +++ b/.claude/hooks/pre-bash-guard.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# +# PreToolUse hook (Bash) for Meshtastic-Android. Two jobs: +# +# 1. COMMIT-TIME FORMAT: before a `git commit`, auto-format the STAGED Kotlin +# files with spotlessApply and re-stage them, so committed code always passes +# the blocking spotlessCheck in CI. Directly addresses the recurring +# "forgot to run spotless -> CI fail" loss. +# - Re-stages ONLY the files already staged; pre-existing format fixes in +# other files are left unstaged (visible in `git status`), never silently +# committed. (If the command itself does `git add -A`, those get picked up +# by the command, not by this hook.) +# - Fails open: a gradle/tooling hiccup warns and ALLOWS the commit. +# +# 2. DESTRUCTIVE-OP CONFIRM: surface a confirmation (permissionDecision "ask") +# before an irreversible git op — force-push or `reset --hard`. Flag-order +# robust, unlike a settings.json prefix pattern. Asks, never hard-denies. +# +# FAILS OPEN throughout: missing jq / parse errors / non-git commands -> exit 0. + +input=$(cat) +command -v jq >/dev/null 2>&1 || exit 0 + +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null) +[ -z "$cmd" ] && exit 0 + +ask() { # $1 = reason; prompt the user to confirm + jq -n --arg r "$1" \ + '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}' + exit 0 +} + +# --- 2. Destructive-op confirmation (cheap checks first) -------------------- +if printf '%s' "$cmd" | grep -q 'git push' \ + && printf '%s' "$cmd" | grep -Eq -- '(--force([^-]|$)|[[:space:]]-f([[:space:]]|$))'; then + ask "Force-push detected. This can overwrite remote history irreversibly. Confirm you intend to force-push (consider --force-with-lease instead). Flagged by .claude/hooks/pre-bash-guard.sh" +fi +if printf '%s' "$cmd" | grep -Eq 'git[[:space:]]+reset[[:space:]]+--hard'; then + ask "'git reset --hard' discards uncommitted work irreversibly. Confirm. Flagged by .claude/hooks/pre-bash-guard.sh" +fi + +# --- 1. Commit-time spotlessApply on staged Kotlin -------------------------- +case "$cmd" in + *"git commit"*) : ;; + *) exit 0 ;; +esac + +cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) +[ -z "$cwd" ] && cwd="$PWD" +repo_root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) || exit 0 +[ -n "$repo_root" ] || exit 0 + +# Staged Kotlin files (added/copied/modified/renamed). Nothing staged -> no-op. +staged=$(git -C "$repo_root" diff --cached --name-only --diff-filter=ACMR -- '*.kt' '*.kts' 2>/dev/null) +[ -z "$staged" ] && exit 0 + +export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" +out=$( (cd "$repo_root" && ./gradlew spotlessApply --console=plain -q) 2>&1 ) +if [ $? -ne 0 ]; then + { + printf '%s\n' "spotless-precommit: spotlessApply failed — allowing the commit anyway (fail-open)." + printf '%s\n' "Run the baseline check before pushing. First lines of output:" + printf '%s\n' "$out" | head -n 20 + } >&2 + exit 0 +fi + +# Re-stage only the originally-staged Kotlin files (preserve staging intent). +while IFS= read -r f; do + [ -n "$f" ] && git -C "$repo_root" add -- "$f" 2>/dev/null +done <<< "$staged" + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 30f837516..76f07396d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,5 @@ { - "$comment": "Team-wide Claude Code token guard. Claude does NOT read .aiexclude/.copilotignore, so the big-file protections live here. 'deny' = never read (Crowdin-managed translations); 'ask' = prompt first (big files rarely needed in full — prefer .skills/compose-ui/strings-index.txt for strings).", + "$comment": "Team-wide Claude Code config. (1) Read guards: Claude does NOT read .aiexclude/.copilotignore, so the big-file protections live here. 'deny' = never read (Crowdin-managed translations); 'ask' = prompt first (big files rarely needed in full — prefer .skills/compose-ui/strings-index.txt for strings; signing keys should never be read into context). (2) Hooks front-run this repo's own CI gates locally — see .claude/hooks/*.sh for what each does. The pre-bash-guard spotlessApply-on-commit hook is the heaviest (runs gradle at commit time); remove its PreToolUse entry if it's too eager for your workflow.", "permissions": { "deny": [ "Read(**/composeResources/**/values-*/*.xml)", @@ -9,7 +9,37 @@ "Read(**/composeResources/**/values/strings.xml)", "Read(**/firmware_releases.json)", "Read(**/composeResources/files/emoji-data.json)", - "Read(**/flatpak-sources*.json)" + "Read(**/flatpak-sources*.json)", + "Read(**/*.keystore)", + "Read(**/*.jks)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit.sh\"", + "timeout": 60, + "statusMessage": "Post-edit checks (strings sort / metadata length / module CI filter)" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/pre-bash-guard.sh\"", + "timeout": 300, + "statusMessage": "Pre-commit spotlessApply on staged Kotlin / destructive-git confirm" + } + ] + } ] } } diff --git a/.claude/skills/proto-bump/SKILL.md b/.claude/skills/proto-bump/SKILL.md new file mode 100644 index 000000000..d2c1367a7 --- /dev/null +++ b/.claude/skills/proto-bump/SKILL.md @@ -0,0 +1,88 @@ +--- +name: proto-bump +description: Change the org.meshtastic:protobufs pin for Meshtastic-Android — either bump to a tagged release (mergeable) or track develop-SNAPSHOT for a preview (draft), adding/removing the transitive resolution-force hack as appropriate. Verifies with test/allTests (not just compile) via the gradle-runner subagent, audits the new field surface, and opens a PR. Use to consume new proto changes or to re-pin a SNAPSHOT draft onto a tag. +disable-model-invocation: true +--- + +# proto-bump + +Changes the upstream Meshtastic protobufs Maven pin. Protobuf models are **not** generated in this repo — they come from `org.meshtastic:protobufs` (Square Wire–built KMP models), pinned in `gradle/libs.versions.toml`. A bump is a catalog edit + verification, never a hand-edit of generated proto. + +> **Renovate already watches the catalog** for new *tagged* `org.meshtastic:protobufs` releases and opens the bump PR for you. Use this skill to **drive/finish** such a bump (verify, audit, adapt call sites), or — the part Renovate can't do — to track an unreleased `develop-SNAPSHOT`. + +## Two modes + +| | Mode A — tagged release | Mode B — develop-SNAPSHOT | +|---|---|---| +| Version | `X.Y.Z` | `develop-SNAPSHOT` | +| Transitive force block | **absent** (remove if present) | **present** (add it) | +| PR state | mergeable | **draft** (un-block when a tag ships) | +| When | a release carries what you need | the change only exists on protobufs `develop` (precedent: #5790, #5834, lockdown re-port) | + +Pick the mode from the argument / context. If the change you need isn't in any tag yet, it's Mode B. + +## Mode A — bump to a tagged release (mergeable) + +1. Read the current pin: `meshtastic-protobufs = "<…>"` in `gradle/libs.versions.toml`. Confirm the target is a real tag at https://github.com/meshtastic/protobufs/releases (or Maven Central), not a SNAPSHOT. +2. Set `meshtastic-protobufs = "X.Y.Z"`. +3. **Re-pin cleanup:** if the transitive force block (see below) is present in the root `build.gradle.kts`, **remove it** — a tagged protobufs is ordered correctly against transitive pins, so the force is unnecessary and misleading once on a release. Also re-check: is takpacket/mqtt now republished against this protobufs? If still pinning an older one, you may need to keep the force (note it in the PR). +4. **Verify** (step "Verification" below) — including `test`/`allTests`. +5. **Audit** the new additive surface (below). +6. Open a normal (non-draft) PR. + +## Mode B — track develop-SNAPSHOT (draft only) + +1. Set `meshtastic-protobufs = "develop-SNAPSHOT"`. +2. **Add the transitive force block** to the bottom of the root `build.gradle.kts` (exact text below). No repository change is needed — `settings.gradle.kts` already declares the Sonatype maven-snapshots repo (`snapshotsOnly()`) and JitPack (`https://jitpack.io`), which is where `develop-SNAPSHOT` resolves from. +3. **Verify** — including `test`/`allTests` (this is the mode where skipping them bites; see below). +4. **Audit** the new additive surface. +5. Open the PR **as a draft**, stating the un-block condition: *"merge once protobufs `vX.Y.Z` is tagged; switch to Mode A (set the tag + remove the force block) first."* + +## The transitive force block (why it exists, exact code) + +`takpacket-sdk` (and the MQTT client) transitively pin a **tagged** `protobufs` (e.g. `2.7.25`). Gradle ranks `2.7.25` **above** `develop-SNAPSHOT` (a numeric component outranks the `develop` string qualifier), so the *test runtime* classpath silently downgrades to the tagged proto while the *common-metadata compile* uses the snapshot. The mismatch throws `NoSuchFieldError`/`NoSuchMethodError` on proto-generated classes **at test runtime** — and crucially `assembleDebug`/`detekt` do **not** catch it; only `test`/`allTests` do. The block forces every `org.meshtastic:protobufs*` variant to the snapshot so compile and runtime agree: + +```kotlin +// ─── TEMPORARY: protobufs develop-SNAPSHOT preview (PR #NNNN) ───────────────────────────────────── +// We track the unreleased protobufs develop-SNAPSHOT. takpacket-sdk-jvm transitively pins a tagged +// protobufs, and Gradle ranks the tag > develop-SNAPSHOT (a numeric part outranks the "develop" +// string qualifier). That downgrades the test *runtime* classpath to the tag while the common-metadata +// *compile* uses the snapshot, yielding NoSuchFieldError/NoSuchMethodError on the proto-generated +// classes at test runtime (assembleDebug/detekt don't catch it; test/allTests do). Force every +// protobufs* variant to the snapshot so compile and runtime agree. Safe while atak.proto is unchanged, +// so takpacket's own message ABI stays compatible with the newer protobufs. +// REMOVE once protobufs is tagged (Mode A) / takpacket + mqtt are republished against it. +allprojects { + configurations.all { + resolutionStrategy.eachDependency { + if (requested.group == "org.meshtastic" && requested.name.startsWith("protobufs")) { + useVersion("develop-SNAPSHOT") + because("preview #NNNN: override takpacket transitive protobufs pin") + } + } + } +} +``` + +Update `#NNNN` to the current PR. Remove this entire block when returning to a tagged release (Mode A step 3). + +## Verification (don't skip test/allTests) + +Dispatch the **gradle-runner** subagent (keep the heavy log out of context). The downgrade failure mode above is a *runtime* classpath problem invisible to compilation, so verification MUST exercise tests: + +- `kmpSmokeCompile` + a broad compile of proto-consuming modules (`core:*`, `feature:*`) — catches *compile-time* breaking changes (renamed/removed fields, changed oneof shapes). +- **`test` and `allTests`** — the only gate that catches the transitive runtime downgrade. Treat a green compile as necessary-but-not-sufficient. + +Triage each compile/test failure: adapt this repo's call sites, or, if a break is unexpected, stop and report rather than papering over it. + +## Audit the new additive surface (recommended) + +New proto versions usually add fields/messages the app doesn't consume yet. Diff the new surface against current usage and list what became implementable. **Caveat from prior audits: verify each reference directly** — automated gap-lists for this repo have been wrong as often as right. Treat the list as candidates, not facts; don't implement them in this PR unless asked. + +## PR + guardrails + +- Write the PR per `.github/copilot-pull-request-instructions.md`: WHY-first; 🛠️ (or 🌟 if it unlocks a user-facing feature); link the upstream release notes (real URL only); "Testing Performed" = the gradle-runner run including `allTests`. +- Never hand-edit or vendor generated proto — this repo consumes the Maven artifact only. +- Keep the change minimal: a bump PR is a catalog edit + the force block (Mode B) + necessary call-site adaptations, not a feature. +- Branch off `main` (the 2.8.0 line) unless told otherwise. +- After a successful change, update the relevant memory pointer (protobufs-sdk-alignment / lora-region-preset-map) so the next session knows the new baseline. diff --git a/.gitignore b/.gitignore index 4f58d8dc0..1e7e47d46 100644 --- a/.gitignore +++ b/.gitignore @@ -86,10 +86,12 @@ docs/_config_local.yml flatpak-sources-*.json flatpak-sources.json offline-repository/ -# Share team-wide Claude config (token-guard deny rules + subagents); keep per-user local settings ignored +# Share team-wide Claude config (settings, subagents, hooks, skills); keep per-user local settings ignored .claude/* !.claude/settings.json !.claude/agents/ +!.claude/hooks/ +!.claude/skills/ build-scan-*.scan # burningmesh capture replay assets (private data — generated via replay_server.py --export) *.fromradio diff --git a/core/konsist/build.gradle.kts b/core/konsist/build.gradle.kts new file mode 100644 index 000000000..1cbc77d8d --- /dev/null +++ b/core/konsist/build.gradle.kts @@ -0,0 +1,38 @@ +/* + * 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 . + */ + +// Architecture-guard module. Holds no production code — only Konsist tests that +// scan every module's source from disk and assert the repo's KMP boundary rules. +// Konsist is JVM-only, so its tests live in jvmTest (it cannot go in commonTest). +// Runs under the existing `allTests` baseline gate via :core:konsist:allTests. +plugins { + alias(libs.plugins.meshtastic.kmp.library) + id("meshtastic.kmp.jvm.android") +} + +kotlin { + android { withHostTest {} } + + sourceSets { + val jvmTest by getting { + dependencies { + implementation(libs.konsist) + implementation(libs.junit) + } + } + } +} diff --git a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CommonMainFrameworkBoundaryTest.kt b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CommonMainFrameworkBoundaryTest.kt new file mode 100644 index 000000000..346f2c783 --- /dev/null +++ b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CommonMainFrameworkBoundaryTest.kt @@ -0,0 +1,43 @@ +/* + * 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.core.konsist + +import com.lemonappdev.konsist.api.Konsist +import com.lemonappdev.konsist.api.verify.assertFalse +import org.junit.Test + +/** + * Enforces the KMP framework-bleed rule from AGENTS.md: shared code in any `commonMain` source set must never depend on + * the JVM (`java.*`) or Android (`android.*`) platform APIs — use the KMP equivalents (Okio, kotlinx-datetime, + * atomicfu, Mutex, …) instead. + * + * Konsist scans every module's Kotlin source from disk, so this single test covers all 37 modules. It runs on the JVM + * (Konsist is JVM-only) under the existing `allTests` baseline gate. + */ +class CommonMainFrameworkBoundaryTest { + private val commonMainFiles = Konsist.scopeFromProject().files.filter { "/src/commonMain/" in it.path } + + @Test + fun `commonMain declares no android imports`() { + commonMainFiles.assertFalse { file -> file.imports.any { it.name.startsWith("android.") } } + } + + @Test + fun `commonMain declares no java imports`() { + commonMainFiles.assertFalse { file -> file.imports.any { it.name.startsWith("java.") } } + } +} diff --git a/feature/car/proguard-rules.pro b/feature/car/proguard-rules.pro index 8cc0a99c2..c11998d40 100644 --- a/feature/car/proguard-rules.pro +++ b/feature/car/proguard-rules.pro @@ -1,9 +1,12 @@ # Car App Library ProGuard/R8 rules -# CarAppService must not be obfuscated (resolved by android:exported="true" in manifest, -# but keep rule ensures R8 doesn't remove it during aggressive shrinking) --keep class org.meshtastic.feature.car.service.MeshtasticCarAppService { *; } +# The service class itself is kept by android:exported="true" in the manifest, and +# obfuscation is off project-wide. The Car App Library constructs it reflectively, so +# pin only the no-arg (its other members are framework overrides, kept as such). +-keepclassmembers class org.meshtastic.feature.car.service.MeshtasticCarAppService { + (); +} -# Keep Koin-annotated classes for runtime DI resolution --keep @org.koin.core.annotation.Single class * { *; } --keep @org.koin.core.annotation.Factory class * { *; } +# Koin @Single/@Factory keeps are provided app-wide by config/proguard/shared-rules.pro +# (applied to the app that consumes this module). Re-add them here only if feature:car is +# ever published/consumed standalone. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 94de967ba..32fa4c42f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ mokkery = "3.4.1" junit5 = "6.1.0" junit-platform = "6.1.0" # aligned with junit5 — JUnit Platform uses 1.x scheme kotest = "6.2.1" +konsist = "0.17.3" testRetry = "1.6.5" turbine = "1.2.1" @@ -238,6 +239,7 @@ junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } kotest-runner-junit6 = { module = "io.kotest:kotest-runner-junit6", version.ref = "kotest" } +konsist = { module = "com.lemonappdev:konsist", version.ref = "konsist" } robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1893844ea..4a0d54338 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -106,6 +106,7 @@ include( ":core:datastore", ":core:di", ":core:domain", + ":core:konsist", ":core:model", ":core:navigation", ":core:network",