Compare commits

..

No commits in common. "main" and "1.2.30" have entirely different histories.
main ... 1.2.30

2112 changed files with 15090 additions and 210893 deletions

View file

@ -1,27 +0,0 @@
# Ignore build artifacts and generated files from Copilot indexing
# This saves context window tokens and prevents Copilot from hallucinating off of minified code.
# Build directories
**/build/**
.gradle/
.idea/
# Android generated files
**/generated/**
.cxx/
.externalNativeBuild/
# Git history & worktrees
.git/
.worktrees/
# Protobuf (Prevents Copilot from suggesting raw protobuf byte buffers)
core/proto/
# Environment and secrets
local.properties
secrets.properties
*.jks
# Agent References (Prevents pollution of project space with external code)
.agent_refs/

View file

@ -1,5 +0,0 @@
{
"context": {
"fileName": ["AGENTS.md", "GEMINI.md"]
}
}

13
.github/FUNDING.yml vendored
View file

@ -1,13 +0,0 @@
# These are supported funding model platforms
github: meshtastic
patreon: # Replace with a single Patreon username
open_collective: meshtastic
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View file

@ -1,182 +0,0 @@
name: Bug Report
description: File a bug report.
title: "[Bug]: "
labels: [bug]
projects: [meshtastic/Meshtastic-Android]
body:
- type: markdown
attributes:
value: |
Thank you for helping to make Meshtastic-Android better by reporting a bug. :hugs:
Please provide as much detail as possible so we can efficiently address your issue and avoid unnecessary back-and-forth.
- type: input
id: contact
attributes:
label: Contact Details
description: How can we get in touch with you if we need more info?
placeholder: ex. email@example.com, discord username
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: "Checklist"
description: |
Please make sure you have done the following before submitting your bug report.
Bug reports that do not meet these criteria will be closed.
Requests that do not meet these criteria will be closed.
Links:
[**OPEN ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues)
[**CLOSED ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues?q=is%3Aissue+is%3Aclosed)
[Contribution Guidelines](https://github.com/meshtastic/Meshtastic-Android/blob/main/README.md#contributing)
options:
- label: |
I am able to reproduce the bug with the latest version.
required: true
- label: |
I have updated to the latest *Alpha* firmware, and am able to reproduce the bug. Many issues are fixed quickly in alpha before the general beta release.
required: true
- label: |
I made sure that there are no existing **OPEN or CLOSED issues** which I could contribute my information to.
required: true
- label: |
I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise.
required: true
- label: |
This issue contains only one bug.
required: true
- label: |
I have read and understood the **Contribution Guidelines**.
required: true
- label: |
I agree to follow this project's Code of Conduct
required: true
- label: |
I actually read this list, and should be taken seriously.
required: false
- type: input
id: app-version
attributes:
label: Affected app version
description: |
In which Meshtastic-Android app version did you encounter the bug?
Can be seen on the bottom of the `Settings` screen in the app.
placeholder: "x.y.z-channel.x (build) flavor"
validations:
required: true
- type: input
id: phone-os
attributes:
label: Affected Android version
description: |
With what operating system (+ version) did you encounter the bug?
placeholder: "Example: Android 14"
validations:
required: true
- type: input
id: phone-model
attributes:
label: Affected phone model
description: |
On what phone did you encounter the bug?
placeholder: "Example: Samsung Galaxy S20 / Google Pixel 8"
validations:
required: true
- type: input
id: hardware-model
attributes:
label: Affected node model
placeholder: "Example: Seeed T1000-E, Heltec v3, etc."
description: |
On which hardware device (Node) did you encounter the bug?
validations:
required: true
- type: input
id: firmware-version
attributes:
label: Affected node firmware version
placeholder: "x.x.x"
description: "Which Meshtastic firmware version did you encounter the bug?"
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to reproduce the bug
description: |
What did you do for the bug to show up?
If you can't cause the bug to show up again reliably (and hence don't have a proper set of steps to give us), please still try to give as many details as possible on how you think you encountered the bug.
placeholder: |
1. Go to '...'
2. Press on '....'
3. Swipe down to '....'
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
description: |
Tell us what happens with the steps given above.
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: |
Tell us what you expect to happen.
- type: textarea
id: screen-media
attributes:
label: Screenshots/Screen recordings
description: |
A picture or video is worth a thousand words.
Provide as much context as possible so we know what we are looking at.
Add screenshots or a screen recording to help explain your problem, provide detailed context to help us know what to look for.
GitHub supports uploads of images and (small) videos directly in the text box.
- type: textarea
id: logs
attributes:
label: Relevant log output
description: |
Logs help us to diagnose and reproduce issues, particularly when they are unique to your setup.
Depending on the issue, the following logs may be useful:
- App logs: This will help with most issues. If possible, provide the relevant output of:
`adb logcat -d | grep com.geeksville.mesh`
- Mesh logs: UI issues, communication issues etc.
- ` App > Settings > Advanced > Debug Panel > Export specific / export all `
- Broader Android logs: Potentially useful if the issue goes beyond the app (connections, network etc.)
`adb logcat -d`
- Firmware logs: Useful for all connection issues with nodes
- These are piped to the USB serial port on the node, the most foolproof is to use the 'open serial' button on the web-flasher interface, and then save the output.
- The app needs to be connecting to the node via Bluetooth or Network for this to work.
render: shell
- type: textarea
id: additional-information
attributes:
label: Additional information
description: |
Any other information you'd like to include, for instance that
* the affected device is foldable or a TV
* you have disabled all animations on your device or otherwise changed system settings
* you are using battery optimization or power saving mode
* you are using a custom Android ROM or launcher
* your ferret chewed your antennas
* you are using a VPN
* you live in a faraday cage
* you dismissed all popups telling you not to do things you shouldn't do without reading them
* ...

View file

@ -1,10 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Meshtastic Android Discussions
url: https://github.com/orgs/meshtastic/discussions/categories/android
about: Please ask and answer questions here.
- name: Meshtastic Website
url: https://meshtastic.org/
about: Docs and other ways to contact us here.

View file

@ -1,61 +0,0 @@
name: Feature Request
description: File a request for new feature or functionality.
title: "[Feature Request]: "
labels: [enhancement]
projects: [meshtastic/Meshtastic-Android]
body:
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: |
Please make sure you have done the following before submitting your feature request.
Requests that do not meet these criteria will be closed.
Links:
[**OPEN ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues)
[**CLOSED ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues?q=is%3Aissue+is%3Aclosed)
[Contribution Guidelines](https://github.com/meshtastic/Meshtastic-Android/blob/main/README.md#contributing)
options:
- label:
I have used the search function for **OPEN ISSUES** to see if someone else has already submitted the same feature request.
required: true
- label: |
I have **also** used the search function for **CLOSED ISSUES** to see if the feature was already implemented and is just waiting to be released, or if the feature was rejected.
required: true
- label: |
I will describe the request with as much detail as possible.
required: true
- label: |
This request contains only one single feature, **not** a list of multiple (related) features.
required: true
- label: |
I have read and understood the **Contribution Guidelines**.
required: true
- label: |
I agree to follow this project's Code of Conduct
required: true
- type: input
id: contact
attributes:
label: Contact Details
description: How can we get in touch with you if we need more info?
placeholder: ex. email@example.com, discord username
validations:
required: false
- type: textarea
id: feature
attributes:
label: Feature or improvement you want
description: Try to be as specific as possible. Please not only explain what the feature does, but also how.
- type: textarea
id: reason
attributes:
label: Why should this be added?
description: |
What problem does the feature solve? In what use-cases is the feature needed?
Is this supported by the firmware? Please provide links to relevant firmware issues or PRs if applicable.
- type: textarea
id: screenshots
attributes:
label: Screenshots / Drawings / Technical details
description: If your request is about (or includes) changing or extending the UI, describe what the UI would look like and how the user would interact with it.

View file

@ -1,180 +0,0 @@
name: Internal testing - Bug Report
description: File a bug report.
title: "[Bug]: "
labels: [bug, ch_testing]
projects: [meshtastic/Meshtastic-Android]
body:
- type: markdown
attributes:
value: |
This Bug report is only for the internal testing builds. If you are not on the list, or didn't seek one out deliberately, you should use the generic Bug Report. :hugs:
Please provide as much detail as possible so we can efficiently reproduce the issue.
- type: input
id: contact
attributes:
label: Contact Details
description: How can we get in touch with you if we need more info?
placeholder: ex. email@example.com, discord username
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: "Checklist"
description: |
Please make sure you have done the following before submitting your bug report.
Bug reports that do not meet these criteria will be closed.
Requests that do not meet these criteria will be closed.
Links:
[**OPEN ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues)
[**CLOSED ISSUES**](https://github.com/meshtastic/Meshtastic-Android/issues?q=is%3Aissue+is%3Aclosed)
[Contribution Guidelines](https://github.com/meshtastic/Meshtastic-Android/blob/main/README.md#contributing)
options:
- label: |
I am able to reproduce the bug with the latest version.
required: true
- label: |
I have updated to the latest *Alpha* firmware, and am able to reproduce the bug. Many issues are fixed quickly in alpha before the general beta release.
required: true
- label: |
I made sure that there are no existing **OPEN or CLOSED issues** which I could contribute my information to.
required: true
- label: |
I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise.
required: true
- label: |
This issue contains only one bug.
required: true
- label: |
I have read and understood the **Contribution Guidelines**.
required: true
- label: |
I agree to follow this project's Code of Conduct
required: true
- type: input
id: app-version
attributes:
label: Affected app version
description: |
In which Meshtastic-Android app version did you encounter the bug?
Can be seen on the bottom of the `Settings` screen in the app.
placeholder: "x.y.z-channel.x (build) flavor"
validations:
required: true
- type: input
id: phone-os
attributes:
label: Affected Android version
description: |
With what operating system (+ version) did you encounter the bug?
placeholder: "Example: Android 14"
validations:
required: true
- type: input
id: phone-model
attributes:
label: Affected phone model
description: |
On what phone did you encounter the bug?
placeholder: "Example: Samsung Galaxy S20 / Google Pixel 8"
validations:
required: true
- type: input
id: hardware-model
attributes:
label: Affected node model
placeholder: "Example: Seeed T1000-E, Heltec v3, etc."
description: |
On which hardware device (Node) did you encounter the bug?
validations:
required: true
- type: input
id: firmware-version
attributes:
label: Affected node firmware version
placeholder: "x.x.x"
description: "Which Meshtastic firmware version did you encounter the bug?"
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to reproduce the bug
description: |
What did you do for the bug to show up?
If you can't cause the bug to show up again reliably (and hence don't have a proper set of steps to give us), please still try to give as many details as possible on how you think you encountered the bug.
placeholder: |
1. Go to '...'
2. Press on '....'
3. Swipe down to '....'
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
description: |
Tell us what happens with the steps given above.
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: |
Tell us what you expect to happen.
- type: textarea
id: screen-media
attributes:
label: Screenshots/Screen recordings
description: |
A picture or video is worth a thousand words.
Provide as much context as possible so we know what we are looking at.
Add screenshots or a screen recording to help explain your problem, provide detailed context to help us know what to look for.
GitHub supports uploads of images and (small) videos directly in the text box.
- type: textarea
id: logs
attributes:
label: Relevant log output
description: |
Logs help us to diagnose and reproduce issues, particularly when they are unique to your setup.
Depending on the issue, the following logs may be useful:
- App logs: This will help with most issues. If possible, provide the relevant output of:
`adb logcat -d | grep com.geeksville.mesh`
- Mesh logs: UI issues, communication issues etc.
- ` App > Settings > Advanced > Debug Panel > Export specific / export all `
- Broader Android logs: Potentially useful if the issue goes beyond the app (connections, network etc.)
`adb logcat -d`
- Firmware logs: Useful for all connection issues with nodes
- These are piped to the USB serial port on the node, the most foolproof is to use the 'open serial' button on the web-flasher interface, and then save the output.
- The app needs to be connecting to the node via Bluetooth or Network for this to work.
render: shell
- type: textarea
id: additional-information
attributes:
label: Additional information
description: |
Any other information you'd like to include, for instance that
* the affected device is foldable or a TV
* you have disabled all animations on your device or otherwise changed system settings
* you are using battery optimization or power saving mode
* you are using a custom Android ROM or launcher
* your ferret chewed your antennas
* you are using a VPN
* you live in a faraday cage
* you dismissed all popups telling you not to do things you shouldn't do without reading them
* ...

View file

@ -1,28 +0,0 @@
## Thank you for sending in a pull request, here's some tips to get started!
(Please delete all these tips and replace with your text)
- Before starting on some new big chunk of code, it it is optional but highly recommended to open an issue first to say "Hey, I think this idea X should be implemented and I'm starting work on it. My general plan is Y, any feedback is appreciated." This will allow other devs to potentially save you time by not accidentally duplicating work etc...
- Mention "#(issue)" in the description, when applicable
- Please do not check in files that don't have real changes
- Please do not reformat lines that you didn't have to change the code on
- If your other co-developers have comments on your PR please tweak as needed
- Do not use any external image service, just paste or drag and drop the image here and it will be uploaded automatically
- Please also enable "Allow edits by maintainers".
<!--
If you have screenshots or recordings to display your change, please include them!
You can use this template for displaying a single screenshot:
<img src="" width="300"/>
or a video recording:
<video src="" width="300"></video>
And if you want to display the state before and after a change, you can use this table template:
| Before | After |
|------|-----|
| <img src="" width="300"/> | <img src="" width="300"/> |
-->

View file

@ -1,40 +0,0 @@
name: Gradle Setup
description: Setup Java and Gradle for KMP builds
inputs:
cache_read_only:
description: 'Whether Gradle cache is read-only'
default: 'true'
jdk_distribution:
description: 'JDK distribution (temurin or jetbrains)'
default: 'temurin'
gradle_encryption_key:
description: 'Encryption key for Gradle remote cache'
required: false
runs:
using: composite
steps:
- name: Copy CI Gradle properties
shell: bash
run: mkdir -p ~/.gradle && cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v6
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
java-version: '21'
distribution: ${{ inputs.jdk_distribution }}
token: ${{ github.token }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ inputs.cache_read_only }}
cache-encryption-key: ${{ inputs.gradle_encryption_key }}
cache-cleanup: on-success
add-job-summary: always
gradle-home-cache-includes: |
caches
notifications
~/.m2/repository/org/robolectric

View file

@ -1,52 +0,0 @@
#
# CI-specific Gradle properties.
#
# This file is copied to ~/.gradle/gradle.properties by the gradle-setup
# composite action, overriding the dev-oriented values in the repo-root
# gradle.properties. Inspired by the nowinandroid & sqldelight patterns.
#
# ── Daemon ────────────────────────────────────────────────────────────
# Single-use CI runners never reuse a daemon, so the startup cost is pure
# overhead. Disabling it also avoids "daemon disappeared" warnings.
org.gradle.daemon=false
# ── Memory ────────────────────────────────────────────────────────────
# Standard GitHub runners have 7 GB RAM. Keep Gradle + Kotlin daemon
# within budget (4g Gradle + 2g Kotlin daemon + 1g OS/tooling headroom).
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
kotlin.daemon.jvm.options=-Xmx2g -XX:+UseParallelGC
# ── Parallelism ───────────────────────────────────────────────────────
org.gradle.parallel=true
org.gradle.workers.max=4
# ── Caching & Configuration ──────────────────────────────────────────
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.configureondemand=false
org.gradle.vfs.watch=false
org.gradle.isolated-projects=true
# ── Kotlin ────────────────────────────────────────────────────────────
# Incremental compilation is wasted on fresh CI checkouts (no prior build
# state to diff against). Disabling avoids the overhead of maintaining
# incremental state that will never be reused.
kotlin.incremental=false
kotlin.code.style=official
kotlin.parallel.tasks.in.project=true
# ── KSP ──────────────────────────────────────────────────────────────
# In CI, KSP incremental processing adds overhead without benefit (fresh
# checkouts). Keep intermodule incremental off (no prior state).
ksp.incremental=false
ksp.run.in.process=true
# ── 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

View file

@ -1,27 +0,0 @@
# GitHub Copilot Commit Message Instructions
<role>
You are an expert Git maintainer enforcing Conventional Commits.
</role>
<instructions>
1. **Format:** Use the Conventional Commits format: `<type>(<scope>): <subject>` (Replace angle brackets with actual text, do NOT output angle brackets).
2. **Types allowed:**
- `feat` (new feature for the user, not a new feature for build script)
- `fix` (bug fix for the user, not a fix to a build script)
- `docs` (changes to the documentation)
- `style` (formatting, missing semi colons, etc; no production code change)
- `refactor` (refactoring production code, e.g. KMP migration, extracting to commonMain)
- `test` (adding missing tests, refactoring tests; no production code change)
- `chore` (updating grunt tasks etc; no production code change)
3. **Scope:** Use the module or logical component as the scope (e.g., `ui`, `navigation`, `ble`, `firmware`, `deps`, `ai`).
4. **Subject line:**
- Use the imperative, present tense: "change" not "changed" nor "changes".
- Do not capitalize the first letter.
- Do not use a period (.) at the end.
- Keep it under 50 characters if possible.
5. **Body (Optional but recommended for large diffs):**
- Leave one blank line after the subject.
- Explain *why* the change was made, not just *what* changed.
- If migrating to KMP or extracting to `commonMain`, explicitly state "Decoupled from Android framework".
</instructions>

View file

@ -1,6 +0,0 @@
# Meshtastic Android - GitHub Copilot Guide
> **Note:** The canonical instructions for all AI Agents have been deduplicated.
You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`.
After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.

View file

@ -1,18 +0,0 @@
# GitHub Copilot Pull Request Instructions
<role>
You are an expert open-source maintainer. Your goal is to write clear, professional, and highly structured Pull Request descriptions based on the provided diffs.
</role>
<instructions>
1. **Remove Boilerplate:** Always delete the "tips" section at the top of the `PULL_REQUEST_TEMPLATE.md` before generating your text.
2. **Context First:** Start with a clear, 1-2 sentence summary of *why* this change is being made. If the branch name or commits reference an issue (e.g., `fix-1234`), explicitly add `Fixes #1234` or `Resolves #1234`.
3. **Structured Changes:** Break down the code changes into bullet points categorized by:
- 🌟 **New Features** (UI, modules, logic)
- 🛠️ **Refactoring & Architecture** (KMP migrations, Koin DI updates)
- 🐛 **Bug Fixes**
- 🧹 **Chores** (Dependencies, formatting, docs)
4. **Architecture Callouts:** If the diff includes moving files from `androidMain` to `commonMain`, or migrating from Android Views to Compose, highlight this as a "KMP Migration Milestone".
5. **Testing Callouts:** If the diff includes changes to `commonTest` or mentions tests, add a section called "Testing Performed" and list the tests that were added/modified.
6. **No "Magic" Text:** Do not invent URLs or insert fake image placeholders. Leave the HTML comment block for images intact so the user can manually add their screenshots.
</instructions>

View file

@ -1,11 +0,0 @@
---
applyTo: "**/androidMain/**/*.kt"
---
# Android Source-Set Rules
- This is `androidMain` — Android framework imports (`android.*`, `java.*`) are allowed here.
- Do NOT put business logic here. Business logic belongs in `commonMain`.
- If you find identical pure-Kotlin logic in both `androidMain` and `jvmMain`, extract it to `commonMain`.
- Use `expect`/`actual` only for small platform primitives. Prefer interfaces + DI.
- Keep `expect` declarations in `FileIo.kt` and shared helpers in `FileIoUtils.kt` to avoid JVM duplicate class errors.

View file

@ -1,10 +0,0 @@
---
applyTo: "build-logic/**/*.kt"
---
# Build-Logic Convention Plugin Rules
- 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.
- Convention plugins: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`.

View file

@ -1,14 +0,0 @@
---
applyTo: "**/*.yml"
excludeAgent: "code-review"
---
# CI Workflow Rules
- Prefer explicit Gradle task paths (`app:lintFdroidDebug`) over shorthand (`lintDebug`).
- CI uses `.github/ci-gradle.properties` — don't assume local `gradle.properties` values.
- CI passes `-Pci=true` to enable full processor usage via `maxParallelForks`.
- Use `fetch-depth: 0` only where needed (spotless ratcheting, version code). Use `fetch-depth: 1` otherwise.
- Desktop build matrix: `macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`.
- Lightweight jobs (labelers, triage, stale): use `ubuntu-24.04-arm` runners.
- Gradle-heavy jobs: use `ubuntu-24.04` runners.

View file

@ -1,20 +0,0 @@
---
applyTo: "**/commonMain/**/*.kt"
---
# KMP commonMain Rules
- NEVER import `java.*` or `android.*` in `commonMain`.
- Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO`.
- Use Okio (`BufferedSource`/`BufferedSink`) instead of `java.io.*`.
- Use `kotlinx.coroutines.sync.Mutex` instead of `java.util.concurrent.locks.*`.
- Use `atomicfu` or Mutex-guarded `mutableMapOf()` instead of `ConcurrentHashMap`.
- Use `jetbrains-*` catalog aliases for lifecycle/navigation dependencies.
- Use `compose-multiplatform-*` catalog aliases for CMP dependencies.
- Never use plain `androidx.compose` dependencies in `commonMain`.
- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings.
- CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`.
- Use `MetricFormatter` from `core:common` for display strings (temperature, voltage, percent, signal). Avoid scattered `formatString("%.1f°C", val)` calls.
- Check `gradle/libs.versions.toml` before adding dependencies.
- Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. Keep `runCatching` only in cleanup/teardown code.
- Use `kotlinx.coroutines.CancellationException`, not `kotlin.coroutines.cancellation.CancellationException`.

12
.github/lsp.json vendored
View file

@ -1,12 +0,0 @@
{
"lspServers": {
"kotlin": {
"command": "kotlin-language-server",
"args": [],
"fileExtensions": {
".kt": "kotlin",
".kts": "kotlin"
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

36
.github/release.yml vendored
View file

@ -1,36 +0,0 @@
# .github/release.yml - GitHub Release Notes Configuration
changelog:
exclude:
labels:
- dependencies
- automation
- release
- repo
- skip-changelog
- chore
- ci
- build
- testing
- test
- refactor
- documentation
- translation
authors:
- renovate[bot]
- dependabot[bot]
- github-actions[bot]
categories:
- title: 🏗️ Features
labels:
- enhancement
- feature
- title: 🛠️ Fixes
labels:
- bug
- bugfix
- fix
- title: 📝 Other Changes
labels:
- '*'

96
.github/renovate.json vendored
View file

@ -1,96 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
":dependencyDashboard",
":semanticCommitTypeAll(chore)",
":ignoreModulesAndTests",
"group:recommended",
"replacements:all",
"workarounds:all"
],
"commitMessageTopic": "{{depName}}",
"labels": [
"dependencies"
],
"git-submodules": {
"enabled": true
},
"bundler": {
"enabled": true
},
"packageRules": [
{
"description": "Automerge non-major updates for stable versions",
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"matchCurrentVersion": "!/^0/",
"automerge": true
},
{
"description": "Automerge patch updates for unstable (0.x) versions",
"matchUpdateTypes": [
"patch",
"pin",
"digest"
],
"matchCurrentVersion": "/^0/",
"automerge": true
},
{
"description": "Automerge pins and digests regardless of version",
"matchUpdateTypes": [
"pin",
"digest"
],
"automerge": true
},
{
"description": "Meshtastic Protobufs changelog link",
"matchPackageNames": [
"https://github.com/meshtastic/protobufs.git"
],
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
"automerge": true
},
{
"description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)",
"groupName": "compose-multiplatform",
"matchPackageNames": [
"/^org\\.jetbrains\\.compose/",
"androidx.compose.runtime:runtime-tracing",
"androidx.compose.ui:ui-test-manifest"
]
},
{
"description": "Restrict sensitive infrastructure to manual minor updates",
"matchUpdateTypes": [
"minor"
],
"matchPackageNames": [
"/^org\\.jetbrains\\.kotlin/",
"/^org\\.jetbrains\\.kotlinx/",
"/^org\\.jetbrains\\.compose/",
"/^com\\.google\\.dagger/",
"/^androidx\\.hilt/",
"/^com\\.google\\.protobuf/",
"/^androidx\\.lifecycle/",
"/^androidx\\.navigation/",
"/^androidx\\.datastore/",
"/^androidx\\.compose\\.material3\\.adaptive/",
"/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/"
],
"automerge": false
},
{
"description": "Disable automerge for major updates (safety net)",
"matchUpdateTypes": [
"major"
],
"automerge": false
}
]
}

43
.github/workflows/android.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: Android CI
# from https://medium.com/@wkrzywiec/github-actions-for-android-first-approach-f616c24aa0f9
on:
- push
- pull_request
jobs:
test:
name: Test
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
with:
submodules: 'recursive'
- name: Loading secrets
env:
gservices: ${{ secrets.gservices }}
mapboxtoken: ${{ secrets.mapboxtoken }}
run: |
# not yet needed echo $gservices > ./app/google-services.json
rm ./app/google-services.json
cp ./app/google-services-example.json ./app/google-services.json
rm ./app/src/main/res/values/mapbox-token.xml
cp ./app/special/mapbox-token.xml ./app/src/main/res/values/
# The following would not be valid XML, don't use yet
# echo $mapboxtoken > ./app/src/main/res/values/mapbox-token.xml
mkdir -p ~/.gradle
echo "MAPBOX_DOWNLOADS_TOKEN=$mapboxtoken" >>~/.gradle/gradle.properties
- name: Mock curfirmware version for CI
run: |
rm ./app/src/main/res/values/curfirmwareversion.xml
cp ./app/special/curfirmwareversion.xml ./app/src/main/res/values/
- name: set up JDK 1.8
uses: actions/setup-java@master
with:
java-version: 1.8
- name: Unit tests
run: bash ./gradlew test --stacktrace

View file

@ -1,166 +0,0 @@
name: Create or Promote Release
on:
workflow_dispatch:
inputs:
base_version:
description: 'Base version for the release (e.g., 2.3.0)'
required: true
channel:
description: 'The channel to create a release for or promote to'
required: true
type: choice
options:
- internal
- closed
- open
- production
dry_run:
description: 'If true, calculates the tag but does not push it or start the release'
required: true
type: boolean
default: false
build_desktop:
description: 'Whether to build the desktop distribution'
required: true
type: boolean
default: false
permissions:
contents: write
pull-requests: read
id-token: write
attestations: write
jobs:
determine-tags:
runs-on: ubuntu-24.04-arm
outputs:
tag_to_process: ${{ steps.calculate_tags.outputs.tag_to_process }}
release_name: ${{ steps.calculate_tags.outputs.release_name }}
final_tag: ${{ steps.calculate_tags.outputs.final_tag }}
from_channel: ${{ steps.calculate_tags.outputs.from_channel }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
- name: Calculate tags
id: calculate_tags
run: |
BASE_VERSION="${{ inputs.base_version }}"
CHANNEL="${{ inputs.channel }}"
if [[ "$CHANNEL" == "internal" ]]; then
# This is a new build, create a new internal tag
LATEST_TAG=$(git tag --list "v${BASE_VERSION}-internal.*" --sort=-v:refname | head -n 1)
if [ -z "$LATEST_TAG" ]; then
INCREMENT=1
else
INCREMENT=$(echo "$LATEST_TAG" | sed -n "s/.*-internal\.\([0-9]*\)/\1/p" | awk '{print $1+1}')
fi
NEW_TAG="v${BASE_VERSION}-internal.${INCREMENT}"
echo "Calculated new tag: $NEW_TAG"
echo "tag_to_process=$NEW_TAG" >> $GITHUB_OUTPUT
echo "release_name=$NEW_TAG" >> $GITHUB_OUTPUT
echo "final_tag=$NEW_TAG" >> $GITHUB_OUTPUT
else
# This is a promotion, find the latest tag from the previous channel to promote
FROM_CHANNEL="internal"
if [[ "$CHANNEL" == "open" ]]; then
FROM_CHANNEL="closed"
elif [[ "$CHANNEL" == "production" ]]; then
FROM_CHANNEL="open"
fi
LATEST_TAG_TO_PROMOTE=$(git tag --list "v${BASE_VERSION}-${FROM_CHANNEL}.*" --sort=-v:refname | head -n 1)
if [ -z "$LATEST_TAG_TO_PROMOTE" ]; then
echo "::error::No ${FROM_CHANNEL} release found for base version ${BASE_VERSION} to promote."
exit 1
fi
echo "Found latest ${FROM_CHANNEL} tag to promote: $LATEST_TAG_TO_PROMOTE"
# Calculate the increment for the TARGET channel
if [[ "$CHANNEL" != "production" ]]; then
LATEST_CHANNEL_TAG=$(git tag --list "v${BASE_VERSION}-${CHANNEL}.*" --sort=-v:refname | head -n 1)
if [ -z "$LATEST_CHANNEL_TAG" ]; then
INCREMENT=1
else
INCREMENT=$(echo "$LATEST_CHANNEL_TAG" | sed -n "s/.*-${CHANNEL}\.\([0-9]*\)/\1/p" | awk '{print $1+1}')
fi
NEW_TAG="v${BASE_VERSION}-${CHANNEL}.${INCREMENT}"
else
# Production is special, it has no increment
NEW_TAG="v${BASE_VERSION}"
fi
echo "New release name will be: $NEW_TAG"
echo "Final tag will be: $NEW_TAG"
echo "from_channel=${FROM_CHANNEL}" >> $GITHUB_OUTPUT
echo "tag_to_process=${LATEST_TAG_TO_PROMOTE}" >> $GITHUB_OUTPUT
echo "release_name=${NEW_TAG}" >> $GITHUB_OUTPUT
echo "final_tag=${NEW_TAG}" >> $GITHUB_OUTPUT
fi
shell: bash
- name: Create and Push Release Tag
if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
env:
FINAL_TAG: ${{ steps.calculate_tags.outputs.final_tag }}
run: |
echo "Tagging and pushing release: $FINAL_TAG"
git tag "$FINAL_TAG"
git push origin "$FINAL_TAG"
shell: bash
call-release-workflow:
if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
needs: determine-tags
uses: ./.github/workflows/release.yml
with:
tag_name: ${{ needs.determine-tags.outputs.final_tag }}
channel: ${{ inputs.channel }}
base_version: ${{ inputs.base_version }}
build_desktop: ${{ inputs.build_desktop }}
secrets: inherit
call-promote-workflow:
if: ${{ !inputs.dry_run && inputs.channel != 'internal' }}
needs: determine-tags
uses: ./.github/workflows/promote.yml
with:
tag_name: ${{ needs.determine-tags.outputs.tag_to_process }}
release_name: ${{ needs.determine-tags.outputs.release_name }}
final_tag: ${{ needs.determine-tags.outputs.final_tag }}
channel: ${{ inputs.channel }}
base_version: ${{ inputs.base_version }}
from_channel: ${{ needs.determine-tags.outputs.from_channel }}
secrets: inherit
cleanup-on-failure:
needs: [determine-tags, call-release-workflow]
if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }}
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Delete Failed or Cancelled Tag
env:
FINAL_TAG: ${{ needs.determine-tags.outputs.final_tag }}
run: |
if [ -n "$FINAL_TAG" ]; then
echo "Release workflow failed or was cancelled. Deleting tag $FINAL_TAG to allow a clean retry..."
git push origin :refs/tags/"$FINAL_TAG" || echo "Tag was not pushed or already deleted."
else
echo "No tag was created to delete."
fi

View file

@ -1,29 +0,0 @@
name: Dependency Submission
on:
push:
branches: [ 'main' ]
workflow_dispatch:
permissions:
contents: write
jobs:
dependency-submission:
runs-on: ubuntu-24.04
if: github.repository == 'meshtastic/Meshtastic-Android'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21
token: ${{ github.token }}
- name: Generate and submit dependency graph
uses: gradle/actions/dependency-submission@v6
with:
build-scan-publish: true
build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
build-scan-terms-of-use-agree: "yes"

View file

@ -1,83 +0,0 @@
# This workflow builds and deploys the Dokka documentation to GitHub Pages.
name: Deploy Documentation
on:
push:
branches:
- main
paths:
# Only rebuild docs when source code changes (Dokka generates from KDoc)
- 'app/src/**'
- 'core/**/src/**'
- 'feature/**/src/**'
- 'desktop/src/**'
- 'build-logic/**'
- 'build.gradle.kts'
- 'settings.gradle.kts'
- '.github/workflows/docs.yml'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
inputs:
ref:
description: 'The branch, tag or SHA to checkout'
required: false
type: string
# Allow this workflow to be called from other workflows
workflow_call:
inputs:
ref:
description: 'The branch, tag or SHA to checkout'
required: false
type: string
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment; cancel queued runs since only the latest
# main state matters for documentation.
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build-docs:
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: 'recursive'
ref: ${{ inputs.ref || '' }}
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build Dokka HTML documentation
run: ./gradlew dokkaGeneratePublicationHtml
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: build/dokka/html
deploy:
if: github.repository == 'meshtastic/Meshtastic-Android'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-24.04-arm
needs: build-docs
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

View file

@ -1,26 +0,0 @@
name: Main CI (Verify & Build)
on:
push:
branches: [ main ]
paths-ignore:
- '**/*.md'
- 'docs/**'
permissions:
contents: read
concurrency:
group: main-${{ github.ref }}
cancel-in-progress: true
jobs:
validate-and-build:
if: github.repository == 'meshtastic/Meshtastic-Android'
uses: ./.github/workflows/reusable-check.yml
with:
run_lint: true
run_unit_tests: false
run_desktop_builds: false
upload_artifacts: true
secrets: inherit

View file

@ -1,71 +0,0 @@
name: Main Push Changelog
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: read
concurrency:
group: main-push-${{ github.ref }}
cancel-in-progress: true
jobs:
main-push-changelog:
name: Generate main push changelog
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Determine last tag
id: last_prod_tag
run: |
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "Found last tag: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Generate changelog from last tag to current
if: steps.last_prod_tag.outputs.tag != ''
uses: mikepenz/release-changelog-builder-action@v6
id: changelog
with:
configuration: .github/release.yml
fromTag: ${{ steps.last_prod_tag.outputs.tag }}
toTag: ${{ github.sha }}
outputFile: main-push-changelog.md
fetchViaCommits: true
fetchReviewers: false
fetchReleaseInformation: false
fetchReviews: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload changelog artifact
if: steps.last_prod_tag.outputs.tag != ''
uses: actions/upload-artifact@v7
with:
name: main-push-changelog
path: main-push-changelog.md
- name: Print main push summary
env:
LAST_TAG: ${{ steps.last_prod_tag.outputs.tag }}
run: |
echo "Pushed to main"
echo "SHA: $GITHUB_SHA"
echo "Actor: $GITHUB_ACTOR"
echo "Ref: $GITHUB_REF"
echo ""
if [ "$LAST_TAG" != "" ]; then
echo "Changelog since last tag ($LAST_TAG)":
echo "----------------------------------------"
cat main-push-changelog.md
else
echo "No tag found. Skipping changelog generation."
fi

View file

@ -1,38 +0,0 @@
name: Android CI (Merge Queue)
on:
merge_group:
types: [checks_requested]
permissions:
contents: read
concurrency:
group: build-mq-${{ github.ref }}
cancel-in-progress: true
jobs:
android-check:
if: github.repository == 'meshtastic/Meshtastic-Android'
uses: ./.github/workflows/reusable-check.yml
with:
run_lint: true
run_unit_tests: true
upload_artifacts: false
secrets: inherit
check-workflow-status:
name: Check Workflow Status
runs-on: ubuntu-24.04-arm
permissions: {}
needs:
- android-check
if: always()
steps:
- name: Check Workflow Status
run: |
if [[ "${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled" ]]; then
echo "::error::Android Check failed"
exit 1
fi
echo "All jobs passed successfully"

View file

@ -1,204 +0,0 @@
name: Issue Triage (Models)
on:
issues:
types: [opened]
permissions:
issues: write
models: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
triage:
if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.issue.user.type != 'Bot' }}
runs-on: ubuntu-24.04-arm
steps:
# ─────────────────────────────────────────────────────────────────────────
# Step 1: Quality check (spam/AI-slop detection) - runs first, exits early if spam
# ─────────────────────────────────────────────────────────────────────────
- name: Detect spam or low-quality content
uses: actions/ai-inference@v2
id: quality
continue-on-error: true
with:
max-tokens: 20
prompt: |
Is this GitHub issue spam, AI-generated slop, or low quality?
Title: ${{ github.event.issue.title }}
Body: ${{ github.event.issue.body }}
Respond with exactly one of: spam, ai-generated, needs-review, ok
system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop.
model: openai/gpt-4o-mini
- name: Apply quality label if needed
if: steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok'
uses: actions/github-script@v9
env:
QUALITY_LABEL: ${{ steps.quality.outputs.response }}
with:
script: |
const label = (process.env.QUALITY_LABEL || '').trim().toLowerCase();
const labelMeta = {
'spam': { color: 'd73a4a', description: 'Possible spam' },
'ai-generated': { color: 'fbca04', description: 'Possible AI-generated low-quality content' },
'needs-review': { color: 'f9d0c4', description: 'Needs human review' },
};
const meta = labelMeta[label];
if (!meta) return;
// Ensure label exists
try {
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, color: meta.color, description: meta.description });
}
// Apply label
await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.issue.number, labels: [label] });
// Set output to skip remaining steps
core.setOutput('is_spam', 'true');
# ─────────────────────────────────────────────────────────────────────────
# Step 2: Duplicate detection - only if not spam
# ─────────────────────────────────────────────────────────────────────────
- name: Detect duplicate issues
if: steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == ''
uses: pelikhan/action-genai-issue-dedup@bdb3b5d9451c1090ffcdf123d7447a5e7c7a2528 # v0.0.19
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# ─────────────────────────────────────────────────────────────────────────
# Step 3: Completeness check + auto-labeling (combined into one AI call)
# ─────────────────────────────────────────────────────────────────────────
- name: Determine if completeness check should be skipped
if: steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == ''
uses: actions/github-script@v9
id: check-skip
with:
script: |
const title = (context.payload.issue.title || '').toLowerCase();
const labels = (context.payload.issue.labels || []).map(label => label.name);
const hasFeatureRequest = title.includes('feature request');
const hasEnhancement = labels.includes('enhancement');
const shouldSkip = hasFeatureRequest && hasEnhancement;
core.setOutput('should_skip', shouldSkip ? 'true' : 'false');
- name: Analyze issue completeness and determine labels
if: (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') && steps.check-skip.outputs.should_skip != 'true'
uses: actions/ai-inference@v2
id: analysis
continue-on-error: true
with:
prompt: |
Analyze this GitHub issue for the Meshtastic Android app and determine if it needs labels.
If this looks like a bug in the Android app (crash, ANR, UI glitch, connection failure, Bluetooth issues, notification problems, map issues), request app logs and explain how to get them:
Android app debug logs:
- Open the Meshtastic app, go to Settings > Debug > Save Logs
- Reproduce the problem, then share/attach the exported log file
Android logcat (if app logs are insufficient):
- Connect phone via USB with USB debugging enabled
- Run: adb logcat -s Meshtastic:* *:E
- Reproduce the problem, then copy/paste the relevant output
Also request key context if missing: Android version, phone model, app version, Meshtastic device model, firmware version, connection type (BLE/USB/TCP), steps to reproduce, expected vs actual.
Respond ONLY with JSON:
{
"complete": true|false,
"comment": "Your helpful comment requesting missing info, or empty string if complete",
"label": "needs-logs" | "needs-info" | "none"
}
Use "needs-logs" if this is an app bug AND no logs are attached.
Use "needs-info" if basic info like firmware version or steps to reproduce are missing.
Use "none" if the issue is complete or is a feature request.
Title: ${{ github.event.issue.title }}
Body: ${{ github.event.issue.body }}
system-prompt: You are a helpful assistant that triages GitHub issues. Be conservative with labels.
model: openai/gpt-4o-mini
- name: Process analysis result
if: (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') && steps.check-skip.outputs.should_skip != 'true' && steps.analysis.outputs.response != ''
uses: actions/github-script@v9
id: process
env:
AI_RESPONSE: ${{ steps.analysis.outputs.response }}
with:
script: |
const raw = (process.env.AI_RESPONSE || '').trim();
let complete = false;
let comment = '';
let label = 'none';
try {
const parsed = JSON.parse(raw);
complete = !!parsed.complete;
comment = (parsed.comment ?? '').toString().trim();
label = (parsed.label ?? 'none').toString().trim().toLowerCase();
} catch {
// If JSON parse fails, treat as incomplete with raw response as comment
complete = false;
comment = raw;
label = 'none';
}
// Validate label
const allowedLabels = new Set(['needs-logs', 'needs-info', 'none']);
if (!allowedLabels.has(label)) label = 'none';
core.setOutput('should_comment', (!complete && comment.length > 0) ? 'true' : 'false');
core.setOutput('comment_body', comment);
core.setOutput('label', label);
- name: Apply triage label
if: steps.process.outputs.label != '' && steps.process.outputs.label != 'none'
uses: actions/github-script@v9
env:
LABEL_NAME: ${{ steps.process.outputs.label }}
with:
script: |
const label = process.env.LABEL_NAME;
const labelMeta = {
'needs-logs': { color: 'cfd3d7', description: 'Device logs requested for triage' },
'needs-info': { color: 'f9d0c4', description: 'More information requested for triage' },
};
const meta = labelMeta[label];
if (!meta) return;
// Ensure label exists
try {
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, color: meta.color, description: meta.description });
}
// Apply label
await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.issue.number, labels: [label] });
- name: Comment on issue
if: steps.process.outputs.should_comment == 'true'
uses: actions/github-script@v9
env:
COMMENT_BODY: ${{ steps.process.outputs.comment_body }}
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: process.env.COMMENT_BODY
});

View file

@ -1,144 +0,0 @@
name: PR Triage (Models)
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
issues: write
models: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
triage:
if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.pull_request.user.type != 'Bot' }}
runs-on: ubuntu-24.04-arm
steps:
# ─────────────────────────────────────────────────────────────────────────
# Step 1: Check if PR already has automation/type labels (skip if so)
# ─────────────────────────────────────────────────────────────────────────
- name: Check existing labels
uses: actions/github-script@v9
id: check-labels
with:
script: |
const skipLabels = new Set(['automation', 'release']);
const typeLabels = new Set(['bugfix', 'enhancement', 'dependencies', 'repo', 'refactor']);
const prLabels = context.payload.pull_request.labels.map(l => l.name);
const shouldSkipAll = prLabels.some(l => skipLabels.has(l));
const hasTypeLabel = prLabels.some(l => typeLabels.has(l));
core.setOutput('skip_all', shouldSkipAll ? 'true' : 'false');
core.setOutput('has_type_label', hasTypeLabel ? 'true' : 'false');
# ─────────────────────────────────────────────────────────────────────────
# Step 2: Quality check (spam/AI-slop detection)
# ─────────────────────────────────────────────────────────────────────────
- name: Detect spam or low-quality content
if: steps.check-labels.outputs.skip_all != 'true'
uses: actions/ai-inference@v2
id: quality
continue-on-error: true
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
with:
max-tokens: 20
prompt: |
Is this GitHub pull request spam, AI-generated slop, or low quality?
Title: ${{ env.PR_TITLE }}
Body: ${{ env.PR_BODY }}
Respond with exactly one of: spam, ai-generated, needs-review, ok
system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop.
model: openai/gpt-4o-mini
- name: Apply quality label if needed
if: steps.check-labels.outputs.skip_all != 'true' && steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok'
uses: actions/github-script@v9
id: quality-label
env:
QUALITY_LABEL: ${{ steps.quality.outputs.response }}
with:
script: |
const label = (process.env.QUALITY_LABEL || '').trim().toLowerCase();
const labelMeta = {
'spam': { color: 'd73a4a', description: 'Possible spam' },
'ai-generated': { color: 'fbca04', description: 'Possible AI-generated low-quality content' },
'needs-review': { color: 'f9d0c4', description: 'Needs human review' },
};
const meta = labelMeta[label];
if (!meta) return;
// Ensure label exists
try {
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, color: meta.color, description: meta.description });
}
// Apply label
await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, labels: [label] });
core.setOutput('is_spam', 'true');
# ─────────────────────────────────────────────────────────────────────────
# Step 3: Auto-label PR type (bugfix/enhancement/refactor)
# ─────────────────────────────────────────────────────────────────────────
- name: Classify PR for labeling
if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '')
uses: actions/ai-inference@v2
id: classify
continue-on-error: true
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
with:
max-tokens: 30
prompt: |
Classify this pull request for the Meshtastic Android app into exactly one category.
Return exactly one of: bugfix, enhancement, refactor
Use bugfix if it fixes a bug, crash, or incorrect behavior.
Use enhancement if it adds a new feature, improves performance, or adds new functionality.
Use refactor if it restructures code without changing behavior, cleans up code, or improves architecture.
Title: ${{ env.PR_TITLE }}
Body: ${{ env.PR_BODY }}
system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label.
model: openai/gpt-4o-mini
- name: Apply type label
if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && steps.classify.outputs.response != ''
uses: actions/github-script@v9
env:
TYPE_LABEL: ${{ steps.classify.outputs.response }}
with:
script: |
const label = (process.env.TYPE_LABEL || '').trim().toLowerCase();
const labelMeta = {
'bugfix': { color: 'd73a4a', description: 'Bug fix' },
'enhancement': { color: 'a2eeef', description: 'New feature or enhancement' },
'refactor': { color: 'c5def5', description: 'Code restructuring without behavior change' },
};
const meta = labelMeta[label];
if (!meta) return;
// Ensure label exists
try {
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: label, color: meta.color, description: meta.description });
}
// Apply label
await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.payload.pull_request.number, labels: [label] });

View file

@ -1,31 +0,0 @@
name: AI Moderator
on:
issues:
types: [opened]
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
spam-detection:
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04-arm
permissions:
issues: write
pull-requests: write
models: read
contents: read
steps:
- uses: actions/checkout@v6
- uses: github/ai-moderator@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
spam-label: 'spam'
ai-label: 'ai-generated'
minimize-detected-comments: true
# Built-in prompt configuration (all enabled by default)
enable-spam-detection: true
enable-link-spam-detection: true
enable-ai-detection: true
# custom-prompt-path: '.github/prompts/my-custom.prompt.yml' # Optional

View file

@ -1,78 +0,0 @@
name: Post-Release Cleanup
on:
workflow_dispatch:
inputs:
base_version:
description: 'The base version to clean up (e.g., 2.3.0)'
required: true
type: string
confirm_deletion:
description: 'WARNING: This is a destructive action. Set to true to perform deletion. Defaults to a dry run.'
required: true
type: boolean
default: false
permissions:
contents: write
jobs:
cleanup_prereleases:
runs-on: ubuntu-24.04-arm
environment: Release
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Cleanup pre-releases and their tags
id: cleanup_releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BASE_VERSION="${{ github.event.inputs.base_version }}"
TAG_PREFIX="v${BASE_VERSION}-"
echo "Searching for pre-releases with tag prefix '$TAG_PREFIX'."
RELEASES_TO_DELETE=$(gh release list --json tagName,isPrerelease --limit 100 | jq -r --arg prefix "$TAG_PREFIX" '.[] | select(.isPrerelease == true and .tagName != null and (.tagName | startswith($prefix))) | .tagName')
if [ -z "$RELEASES_TO_DELETE" ]; then
echo "No pre-releases found for base version $BASE_VERSION."
else
if [[ "${{ github.event.inputs.confirm_deletion }}" == "true" ]]; then
echo "!!! DELETING RELEASES AND TAGS !!!"
echo "The following pre-releases and their tags will be deleted:"
echo "$RELEASES_TO_DELETE"
echo "$RELEASES_TO_DELETE" | xargs -n 1 gh release delete --cleanup-tag --yes
else
echo "DRY RUN: The following pre-releases and their tags would be deleted:"
echo "$RELEASES_TO_DELETE"
fi
fi
- name: Cleanup dangling pre-release tags
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BASE_VERSION="${{ github.event.inputs.base_version }}"
TAG_PREFIX="v${BASE_VERSION}-"
echo "Searching for any remaining remote pre-release tags with prefix '$TAG_PREFIX'."
# This finds all remote tags matching the pattern. Some may have been deleted in the previous step.
TAGS_TO_DELETE=$(git ls-remote --tags origin "refs/tags/${TAG_PREFIX}*" | awk '{print $2}' | sed 's|refs/tags/||')
if [ -z "$TAGS_TO_DELETE" ]; then
echo "No dangling pre-release tags found."
else
if [[ "${{ github.event.inputs.confirm_deletion }}" == "true" ]]; then
echo "!!! DELETING DANGLING TAGS !!!"
echo "The following pre-release tags will be deleted:"
# We pipe to xargs which will run the command for each tag.
# If a tag was already deleted by the previous 'release delete' step, this will fail for that tag.
# We add '|| true' to ignore any errors and ensure the workflow doesn't fail.
echo "$TAGS_TO_DELETE" | xargs -n 1 -I {} sh -c 'git push --delete origin {} || true'
else
echo "DRY RUN: The following dangling pre-release tags would be deleted:"
echo "$TAGS_TO_DELETE"
fi
fi

View file

@ -1,37 +0,0 @@
name: Check PR Labels
on:
pull_request:
types: [edited, labeled]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
pull-requests: read
contents: read
jobs:
check-label:
# Skip bot PRs — they already have labels from the workflows/bots that create them
if: >-
github.event.pull_request.user.login != 'renovate[bot]' &&
github.event.pull_request.user.login != 'github-actions[bot]' &&
github.event.pull_request.user.login != 'dependabot[bot]' &&
github.event.pull_request.head.ref != 'scheduled-updates' &&
github.event.pull_request.head.ref != 'l10n_main'
runs-on: ubuntu-24.04-arm
steps:
- name: Check for PR labels
uses: actions/github-script@v9
with:
script: |
// Extract labels from the payload directly to avoid extra API calls
const latestLabels = context.payload.pull_request.labels.map(label => label.name);
const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor'];
console.log('Labels from payload:', latestLabels);
const hasRequiredLabel = latestLabels.some(label => requiredLabels.includes(label));
if (!hasRequiredLabel) {
core.setFailed(`PR must have at least one of the following labels before it can be merged: ${requiredLabels.join(', ')}.`);
}

View file

@ -1,190 +0,0 @@
name: Promote Release
on:
workflow_call:
inputs:
base_version:
description: 'The base version for the release (e.g., 2.3.0)'
required: true
type: string
tag_name:
description: 'The tag that triggered the release'
required: true
type: string
release_name:
description: 'The desired name for the GitHub release'
required: true
type: string
final_tag:
description: 'The final tag for the release'
required: true
type: string
commit_sha:
description: 'The commit SHA to tag'
required: false
type: string
channel:
description: 'The channel to promote to'
required: true
type: string
from_channel:
description: 'The channel to promote from'
required: true
type: string
secrets:
GSERVICES:
required: true
KEYSTORE:
required: true
KEYSTORE_FILENAME:
required: true
KEYSTORE_PROPERTIES:
required: true
DATADOG_APPLICATION_ID:
required: true
DATADOG_CLIENT_TOKEN:
required: true
GOOGLE_MAPS_API_KEY:
required: true
GOOGLE_PLAY_JSON_KEY:
required: true
GRADLE_ENCRYPTION_KEY:
required: true
DISCORD_WEBHOOK_ANDROID:
required: false
concurrency:
group: ${{ github.workflow }}-${{ inputs.tag_name }}
cancel-in-progress: true
permissions:
contents: write
pull-requests: read
id-token: write
attestations: write
jobs:
prepare-build-info:
runs-on: ubuntu-24.04-arm
outputs:
APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.commit_sha || inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Prep APP_VERSION_NAME
id: prep_version
env:
INPUT_TAG_NAME: ${{ inputs.tag_name }}
run: |
VERSION_NAME=$(echo $INPUT_TAG_NAME | sed 's/-.*//' | sed 's/v//')
echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_OUTPUT
echo "Parsed Version: $VERSION_NAME"
- name: Extract VERSION_CODE_OFFSET from config.properties
id: get_version_code_offset
run: |
OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2)
echo "VERSION_CODE_OFFSET=$OFFSET" >> $GITHUB_OUTPUT
- name: Calculate Version Code from Git Commit Count
id: calculate_version_code
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
OFFSET=${{ steps.get_version_code_offset.outputs.VERSION_CODE_OFFSET }}
VERSION_CODE=$((COMMIT_COUNT + OFFSET))
echo "versionCode=$VERSION_CODE" >> $GITHUB_OUTPUT
shell: bash
promote-release:
runs-on: ubuntu-24.04-arm
environment: Release
needs: [ prepare-build-info ]
steps:
- name: Promote to next channel
uses: kevin-david/promote-play-release@v1.2.0
with:
service-account-json-raw: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
package-name: 'com.geeksville.mesh'
from-track: ${{ inputs.from_channel == 'closed' && 'NewAlpha' || (inputs.from_channel == 'open' && 'beta' || 'internal') }}
to-track: ${{ inputs.channel == 'closed' && 'NewAlpha' || (inputs.channel == 'open' && 'beta' || 'production') }}
user-fraction: ${{ (inputs.channel == 'production' && '0.1') || (inputs.channel == 'open' && '0.5') || '1.0' }}
update-github-release:
runs-on: ubuntu-24.04-arm
needs: [ prepare-build-info, promote-release ]
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.commit_sha || inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Push Git Tag on Success
if: ${{ inputs.commit_sha != '' }}
run: |
git tag ${{ inputs.final_tag }} ${{ inputs.commit_sha }}
git push origin ${{ inputs.final_tag }}
- name: Update GitHub Release with gh CLI
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release edit ${{ inputs.tag_name }} \
--tag ${{ inputs.final_tag }} \
--title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \
--draft=false \
--prerelease=${{ inputs.channel != 'production' }}
- name: Notify Discord
if: ${{ inputs.channel != 'internal' }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_ANDROID }}
VERSION: ${{ inputs.final_tag }}
CHANNEL: ${{ inputs.channel }}
run: |
if [[ -z "$DISCORD_WEBHOOK" ]]; then
echo "No Discord webhook provided. Skipping notification."
exit 0
fi
# Determine Track Name for Display
if [ "$CHANNEL" == "closed" ]; then TRACK="Alpha (Closed)"; fi
if [ "$CHANNEL" == "open" ]; then TRACK="Beta (Open)"; fi
if [ "$CHANNEL" == "production" ]; then TRACK="Production"; fi
# Construct JSON Payload
PAYLOAD=$(cat <<EOF
{
"content": null,
"embeds": [
{
"title": "🚀 New Android Release: $VERSION",
"description": "A new build has been promoted to the **$TRACK** track.",
"color": 5763719,
"fields": [
{
"name": "Track",
"value": "$TRACK",
"inline": true
},
{
"name": "Version",
"value": "$VERSION",
"inline": true
}
],
"url": "https://github.com/meshtastic/Meshtastic-Android/releases/tag/$VERSION"
}
]
}
EOF
)
curl -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK"

View file

@ -1,51 +0,0 @@
name: Publish Core Libraries
on:
release:
types: [created]
workflow_dispatch:
inputs:
version_suffix:
description: 'Version suffix (e.g. -alpha01, -SNAPSHOT)'
required: false
default: '-SNAPSHOT'
jobs:
publish:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Configure Version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
VERSION_SUFFIX: ${{ inputs.version_suffix }}
run: |
if [[ "$EVENT_NAME" == "release" ]]; then
echo "VERSION_NAME=$RELEASE_TAG" >> $GITHUB_ENV
else
# Use a timestamp-based version for manual/branch builds to avoid collisions
# or use the base version + suffix
BASE_VERSION=$(grep "VERSION_NAME_BASE" config.properties | cut -d'=' -f2)
echo "VERSION_NAME=${BASE_VERSION}${VERSION_SUFFIX}" >> $GITHUB_ENV
fi
- name: Publish to GitHub Packages
run: ./gradlew :core:api:publish :core:model:publish :core:proto:publish
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,67 +0,0 @@
name: "Pull Request Labeler"
on:
pull_request_target:
types: [opened, synchronize]
# Do not execute arbitrary code on this workflow.
# See warnings at https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
labeler:
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-24.04-arm
steps:
- name: Auto-label PR
uses: actions/github-script@v9
with:
script: |
const branch = context.payload.pull_request.head.ref;
const labels = new Set();
// enhancement: branch contains feat
if (/feat/i.test(branch)) labels.add('enhancement');
// bugfix: branch starts with fix or bug
if (/^(fix|bug)/i.test(branch)) labels.add('bugfix');
// refactor: branch starts with refactor
if (/^refactor/i.test(branch)) labels.add('refactor');
// repo: branch contains repo or ci
if (/repo|ci/i.test(branch)) {
labels.add('repo');
} else {
// Also label 'repo' if .github files were changed (needs one API call)
try {
const files = await github.paginate(
github.rest.pulls.listFiles,
{ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number, per_page: 100 },
(res) => res.data.map(f => f.filename)
);
if (files.some(f => f.startsWith('.github/'))) labels.add('repo');
} catch (e) {
core.warning(`Could not list PR files (rate limited?): ${e.message}`);
}
}
if (labels.size > 0) {
const labelArray = [...labels];
core.info(`Applying labels: ${labelArray.join(', ')}`);
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels: labelArray,
});
} catch (e) {
core.warning(`Could not apply labels (rate limited?): ${e.message}`);
}
} else {
core.info('No labels matched for this PR.');
}

View file

@ -1,134 +0,0 @@
name: Pull Request CI
on:
pull_request:
branches: [ main ]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# 1. CHANGE DETECTION: Prevents unnecessary builds
check-changes:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
runs-on: ubuntu-24.04-arm
outputs:
android: ${{ steps.filter.outputs.android }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
token: ''
filters: |
android:
# CI/workflow implementation
- '.github/workflows/**'
- '.github/actions/**'
# Product modules validated by reusable-check
- 'app/**'
- 'baselineprofile/**'
- 'desktop/**'
- 'core/**'
- 'feature/**'
# Shared build infrastructure
- 'build-logic/**'
- 'config/**'
- 'gradle/**'
# Root build entrypoints/config that can alter task graph or outputs
- 'build.gradle.kts'
- 'config.properties'
- 'compose_compiler_config.conf'
- 'gradle.properties'
- 'gradlew'
- 'gradlew.bat'
- 'settings.gradle.kts'
- 'test.gradle.kts'
# 1b. FILTER DRIFT CHECK: Ensures check-changes stays aligned with module roots
verify-check-changes-filter:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v6
- name: Verify module roots are represented in check-changes filter
run: |
python3 - <<'PY'
import re
from pathlib import Path
settings = Path('settings.gradle.kts').read_text()
workflow = Path('.github/workflows/pull-request.yml').read_text()
module_roots = {
module.split(':')[0]
for module in re.findall(r'":([^"]+)"', settings)
}
allowed_extra_roots = {'baselineprofile'}
expected_roots = module_roots | allowed_extra_roots
filter_paths = {
path.split('/')[0]
for path in re.findall(r"-\s*'([^']+/\*\*)'", workflow)
}
actual_module_roots = filter_paths & expected_roots
missing = sorted(expected_roots - actual_module_roots)
unexpected = sorted(actual_module_roots - expected_roots)
if missing or unexpected:
print('check-changes filter drift detected:')
if missing:
print(' Missing roots:', ', '.join(missing))
if unexpected:
print(' Unexpected roots:', ', '.join(unexpected))
raise SystemExit(1)
print('check-changes filter is aligned with settings.gradle module roots.')
PY
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
# We disable coverage and desktop builds for PRs to keep feedback fast
# (< 10 mins). Desktop compilation is already covered by the :desktop:test
# task in the shard-app test shard.
validate-and-build:
needs: check-changes
if: needs.check-changes.outputs.android == 'true'
uses: ./.github/workflows/reusable-check.yml
with:
run_lint: true
run_unit_tests: true
run_coverage: false
run_desktop_builds: false
upload_artifacts: true
secrets: inherit
# 3. WORKFLOW STATUS: Ensures required checks are satisfied
check-workflow-status:
name: Check Workflow Status
runs-on: ubuntu-24.04-arm
permissions: {}
needs: [check-changes, verify-check-changes-filter, validate-and-build]
if: always()
steps:
- name: Check Workflow Status
run: |
if [[ "${{ needs.verify-check-changes-filter.result }}" == "failure" || "${{ needs.verify-check-changes-filter.result }}" == "cancelled" ]]; then
echo "::error::check-changes filter verification failed"
exit 1
fi
# If changes were detected but build failed, fail the status check
if [[ "${{ needs.check-changes.outputs.android }}" == "true" && ("${{ needs.validate-and-build.result }}" == "failure" || "${{ needs.validate-and-build.result }}" == "cancelled") ]]; then
echo "::error::Android Check failed"
exit 1
fi
# If no changes were detected, this still succeeds to satisfy required status check
echo "Workflow status satisfied."

View file

@ -1,353 +0,0 @@
name: Make Release
on:
workflow_call:
inputs:
base_version:
description: 'The base version for the release (e.g., 2.3.0)'
required: true
type: string
tag_name:
description: 'The tag that triggered the release'
required: true
type: string
commit_sha:
description: 'The commit SHA to build and tag'
required: false
type: string
channel:
description: 'The channel to create a release for or promote to'
required: true
type: string
build_desktop:
description: 'Whether to build the desktop distribution'
required: false
type: boolean
default: false
secrets:
GSERVICES:
required: true
KEYSTORE:
required: true
KEYSTORE_FILENAME:
required: true
KEYSTORE_PROPERTIES:
required: true
DATADOG_APPLICATION_ID:
required: true
DATADOG_CLIENT_TOKEN:
required: true
GOOGLE_MAPS_API_KEY:
required: true
GOOGLE_PLAY_JSON_KEY:
required: true
GRADLE_ENCRYPTION_KEY:
required: true
GRADLE_CACHE_URL:
required: false
GRADLE_CACHE_USERNAME:
required: false
GRADLE_CACHE_PASSWORD:
required: false
INTERNAL_BUILDS_HOST:
required: false
INTERNAL_BUILDS_HOST_PAT:
required: false
concurrency:
group: ${{ github.workflow }}-${{ inputs.tag_name }}
cancel-in-progress: true
permissions:
contents: write
pull-requests: read
id-token: write
attestations: write
jobs:
prepare-build-info:
runs-on: ubuntu-24.04-arm
outputs:
APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Prep APP_VERSION_NAME
id: prep_version
env:
INPUT_TAG_NAME: ${{ inputs.tag_name }}
run: |
VERSION_NAME=$(echo $INPUT_TAG_NAME | sed 's/-.*//' | sed 's/v//')
echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_OUTPUT
echo "Parsed Version: $VERSION_NAME"
- name: Extract VERSION_CODE_OFFSET from config.properties
id: get_version_code_offset
run: |
OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2)
echo "VERSION_CODE_OFFSET=$OFFSET" >> $GITHUB_OUTPUT
- name: Calculate Version Code from Git Commit Count
id: calculate_version_code
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
OFFSET=${{ steps.get_version_code_offset.outputs.VERSION_CODE_OFFSET }}
VERSION_CODE=$((COMMIT_COUNT + OFFSET))
echo "versionCode=$VERSION_CODE" >> $GITHUB_OUTPUT
shell: bash
release-google:
runs-on: ubuntu-24.04
needs: [prepare-build-info]
environment: Release
env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: 'false'
- name: Load secrets
env:
GSERVICES: ${{ secrets.GSERVICES }}
KEYSTORE: ${{ secrets.KEYSTORE }}
KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }}
KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }}
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: |
rm -f ./app/google-services.json
echo $GSERVICES > ./app/google-services.json
echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties
echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties
echo "MAPS_API_KEY=$GOOGLE_MAPS_API_KEY" >> ./secrets.properties
echo "$GOOGLE_PLAY_JSON_KEY" > ./fastlane/play-store-credentials.json
- name: Setup Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4.9'
bundler-cache: true
- name: Build and Deploy Google Play to Internal Track with Fastlane
env:
VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
run: bundle exec fastlane internal
- name: List outputs
run: ls -R app/build/outputs/
- name: Upload Google AAB artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: google-aab
path: app/build/outputs/bundle/googleRelease/app-google-release.aab
retention-days: 1
- name: Upload Google APK artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: google-apk
path: app/build/outputs/apk/google/release/*.apk
retention-days: 1
- name: Attest Google AAB provenance
if: success()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/bundle/googleRelease/app-google-release.aab
- name: Attest Google APK provenance
if: success()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/apk/google/release/*.apk
release-fdroid:
runs-on: ubuntu-24.04
needs: [prepare-build-info]
environment: Release
env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: 'false'
- name: Load secrets
env:
KEYSTORE: ${{ secrets.KEYSTORE }}
KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }}
KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }}
run: |
echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
- name: Setup Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4.9'
bundler-cache: true
- name: Build F-Droid with Fastlane
env:
VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
run: bundle exec fastlane fdroid_build
- name: List outputs
run: ls -R app/build/outputs/
- name: Upload F-Droid APK artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: fdroid-apk
path: app/build/outputs/apk/fdroid/release/*.apk
retention-days: 1
- name: Attest F-Droid APK provenance
if: success()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/apk/fdroid/release/*.apk
release-desktop:
if: ${{ inputs.build_desktop }}
runs-on: ${{ matrix.os }}
needs: [prepare-build-info]
environment: Release
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]
env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: 'false'
- name: Install dependencies for AppImage
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libfuse2
- name: Package Native Distributions
env:
ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
APPIMAGE_EXTRACT_AND_RUN: 1
run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS -PaboutLibraries.release=true --no-daemon
- name: List Desktop Binaries
if: runner.os == 'Linux'
run: ls -R desktop/build/compose/binaries/main-release
- name: Upload Desktop Artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: desktop-${{ runner.os }}-${{ runner.arch }}
path: |
desktop/build/compose/binaries/main-release/*/*.dmg
desktop/build/compose/binaries/main-release/*/*.msi
desktop/build/compose/binaries/main-release/*/*.exe
desktop/build/compose/binaries/main-release/*/*.deb
desktop/build/compose/binaries/main-release/*/*.rpm
desktop/build/compose/binaries/main-release/*/*.AppImage
retention-days: 1
if-no-files-found: ignore
github-release:
if: ${{ !cancelled() && !failure() }}
runs-on: ubuntu-24.04-arm
needs: [prepare-build-info, release-google, release-fdroid, release-desktop]
env:
INTERNAL_BUILDS_HOST: ${{ secrets.INTERNAL_BUILDS_HOST }}
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.tag_name }}
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: ./artifacts
- name: Create or Update GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ inputs.tag_name }}
target_commitish: ${{ inputs.commit_sha || github.sha }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
generate_release_notes: true
files: ./artifacts/**/*
draft: true
prerelease: true
- name: Create or Update internal GitHub Release
continue-on-error: true
if: ${{ env.INTERNAL_BUILDS_HOST != '' }}
uses: softprops/action-gh-release@v3
with:
repository: ${{ secrets.INTERNAL_BUILDS_HOST }}
token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }}
tag_name: ${{ inputs.tag_name }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
generate_release_notes: false
files: ./artifacts/**/*
draft: false
prerelease: true

View file

@ -1,315 +0,0 @@
name: Reusable Android Check
on:
workflow_call:
inputs:
run_lint:
type: boolean
default: true
run_unit_tests:
type: boolean
default: true
run_coverage:
type: boolean
default: true
run_desktop_builds:
type: boolean
default: true
upload_artifacts:
type: boolean
default: true
secrets:
GRADLE_ENCRYPTION_KEY:
required: false
CODECOV_TOKEN:
required: false
DATADOG_APPLICATION_ID:
required: false
DATADOG_CLIENT_TOKEN:
required: false
GOOGLE_MAPS_API_KEY:
required: false
GRADLE_CACHE_URL:
required: false
GRADLE_CACHE_USERNAME:
required: false
GRADLE_CACHE_PASSWORD:
required: false
env:
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GITHUB_TOKEN: ${{ github.token }}
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
# Fallback VERSION_CODE for the lint-check job itself (which computes the real
# value from git). Downstream jobs override this with the git-derived value.
VERSION_CODE: ${{ github.run_number }}
jobs:
# ── Lint & Static Analysis ──────────────────────────────────────────
lint-check:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 30
outputs:
cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
version_code: ${{ steps.version_code.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
filter: 'blob:none'
submodules: true
- name: Determine cache read-only setting
id: cache_config
shell: bash
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]] || [[ "${{ github.event_name }}" == "merge_group" ]] || [[ "${{ github.ref }}" == gh-readonly-queue/* ]]; then
echo "cache_read_only=false" >> "$GITHUB_OUTPUT"
else
echo "cache_read_only=true" >> "$GITHUB_OUTPUT"
fi
- name: Calculate version code from git commit count
id: version_code
shell: bash
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2 || echo 0)
VERSION_CODE=$((COMMIT_COUNT + OFFSET))
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
- name: Lint, Analysis & KMP Smoke Compile
if: inputs.run_lint == true
run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan
- name: KMP Smoke Compile (lint skipped)
if: inputs.run_lint == false
run: ./gradlew kmpSmokeCompile -Pci=true --continue --scan
# ── Sharded Unit Tests ──────────────────────────────────────────────
# Tests are split into 3 shards that run in parallel:
# shard-core: core:* KMP module tests (allTests)
# shard-feature: feature:* KMP module tests (allTests)
# shard-app: Pure-Android/JVM tests (app, desktop, core:barcode, etc.)
test-shards:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 45
needs: lint-check
if: inputs.run_unit_tests == true
env:
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
strategy:
fail-fast: false
matrix:
shard:
- name: shard-core
tasks: >-
:core:ble:allTests
:core:common:allTests
:core:data:allTests
:core:database:allTests
:core:domain:allTests
:core:model:allTests
:core:navigation:allTests
:core:network:allTests
:core:prefs:allTests
:core:repository:allTests
:core:service:allTests
:core:takserver:allTests
:core:testing:allTests
:core:ui:allTests
kover: >-
:core:ble:koverXmlReport
:core:common:koverXmlReport
:core:data:koverXmlReport
:core:database:koverXmlReport
:core:domain:koverXmlReport
:core:model:koverXmlReport
:core:navigation:koverXmlReport
:core:network:koverXmlReport
:core:prefs:koverXmlReport
:core:repository:koverXmlReport
:core:service:koverXmlReport
:core:takserver:koverXmlReport
:core:testing:koverXmlReport
:core:ui:koverXmlReport
- name: shard-feature
tasks: >-
:feature:connections:allTests
:feature:firmware:allTests
:feature:intro:allTests
:feature:map:allTests
:feature:messaging:allTests
:feature:node:allTests
:feature:settings:allTests
kover: >-
:feature:connections:koverXmlReport
:feature:firmware:koverXmlReport
:feature:intro:koverXmlReport
:feature:map:koverXmlReport
:feature:messaging:koverXmlReport
:feature:node:koverXmlReport
:feature:settings:koverXmlReport
- name: shard-app
tasks: >-
:app:testFdroidDebugUnitTest
:app:testGoogleDebugUnitTest
:desktop:test
:core:barcode:testFdroidDebugUnitTest
:core:barcode:testGoogleDebugUnitTest
kover: >-
:app:koverXmlReportFdroidDebug
:app:koverXmlReportGoogleDebug
:core:barcode:koverXmlReportFdroidDebug
:core:barcode:koverXmlReportGoogleDebug
:desktop:koverXmlReport
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- name: Run Tests & Coverage (${{ matrix.shard.name }})
run: |
kover_tasks=""
if [[ "${{ inputs.run_coverage }}" == "true" ]]; then
kover_tasks="${{ matrix.shard.kover }}"
fi
./gradlew ${{ matrix.shard.tasks }} $kover_tasks -Pci=true --continue --scan
- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
flags: ${{ matrix.shard.name }}
fail_ci_if_error: false
report_type: test_results
files: "**/build/test-results/**/*.xml"
- name: Upload coverage to Codecov
if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
flags: ${{ matrix.shard.name }}
fail_ci_if_error: false
files: "**/build/reports/kover/report*.xml"
- name: Upload shard reports
if: ${{ always() && inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: reports-${{ matrix.shard.name }}
path: |
**/build/reports
**/build/test-results
retention-days: 7
# ── Android Build ────────────────────────────────────────────────────
android-check:
runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 60
needs: lint-check
env:
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- name: Build Android APKs
run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan
- name: Upload debug artifact
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: app-debug-apks
path: app/build/outputs/apk/*/debug/*.apk
retention-days: 7
- name: Report App Size
if: always()
run: |
echo "### App Size Report" >> $GITHUB_STEP_SUMMARY
echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
# ── Desktop Build ───────────────────────────────────────────────────
build-desktop:
name: Build Desktop Debug (${{ matrix.os }})
if: inputs.run_desktop_builds == true
runs-on: ${{ matrix.os }}
permissions:
contents: read
timeout-minutes: 60
needs: lint-check
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]
env:
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- name: Build Desktop
run: ./gradlew :desktop:createDistributable -Pci=true --scan
- name: Upload Desktop artifact
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: desktop-app-${{ runner.os }}-${{ runner.arch }}
path: desktop/build/compose/binaries/main/app/
retention-days: 7

View file

@ -1,145 +0,0 @@
name: Scheduled Updates (Firmware, Hardware, Translations)
on:
schedule:
- cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost)
workflow_dispatch: # Allow manual triggering
jobs:
update_assets:
runs-on: ubuntu-24.04
if: github.repository == 'meshtastic/Meshtastic-Android'
permissions:
contents: write # To commit files and push branches
pull-requests: write # To create pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
- name: Update firmware releases list
run: |
firmware_file_path="app/src/main/assets/firmware_releases.json"
temp_firmware_file="/tmp/new_firmware_releases.json"
echo "Fetching latest firmware releases..."
curl -s --fail https://api.meshtastic.org/github/firmware/list > "$temp_firmware_file"
if ! jq empty "$temp_firmware_file" 2>/dev/null; then
echo "::error::Firmware API returned invalid JSON data. Skipping firmware update."
else
if [ ! -f "$firmware_file_path" ] || ! jq --sort-keys . "$temp_firmware_file" | diff -q - <(jq --sort-keys . "$firmware_file_path"); then
echo "Changes detected in firmware list or local file missing. Updating $firmware_file_path."
cp "$temp_firmware_file" "$firmware_file_path"
else
echo "No changes detected in firmware list."
fi
fi
- name: Update hardware list
run: |
hardware_file_path="app/src/main/assets/device_hardware.json"
temp_hardware_file="/tmp/new_device_hardware.json"
echo "Fetching latest device hardware data..."
curl -s --fail https://api.meshtastic.org/resource/deviceHardware > "$temp_hardware_file"
if ! jq empty "$temp_hardware_file" 2>/dev/null; then
echo "::error::Hardware API returned invalid JSON data. Skipping hardware update."
else
if [ ! -f "$hardware_file_path" ] || ! jq --sort-keys . "$temp_hardware_file" | diff -q - <(jq --sort-keys . "$hardware_file_path"); then
echo "Changes detected in hardware list or local file missing. Updating $hardware_file_path."
cp "$temp_hardware_file" "$hardware_file_path"
else
echo "No changes detected in hardware list."
fi
fi
- name: Sync with Crowdin
uses: crowdin/github-action@v2
with:
base_url: 'https://meshtastic.crowdin.com/api/v2'
config: 'crowdin.yml'
crowdin_branch_name: 'main'
upload_sources: true
upload_sources_args: '--preserve-hierarchy'
upload_translations: false
download_translations: true
download_translations_args: '--preserve-hierarchy'
create_pull_request: false
commit_message: 'chore(l10n): New Crowdin Translations from scheduled update'
push_translations: false
push_sources: false
localization_branch_name: ${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Fix file permissions
run: sudo chown -R $USER:$USER .
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: 'false'
- name: Update Graphs
run: ./gradlew graphUpdate
continue-on-error: true
- name: Create Pull Request if changes occurred
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
commit-message: |
chore: Scheduled updates (Firmware, Hardware, Translations, Graphs)
Automated updates for:
- Firmware releases list
- Device hardware list
- Crowdin source string uploads
- Crowdin translation downloads
- Module dependency graphs
title: 'chore: Scheduled updates (Firmware, Hardware, Translations, Graphs)'
body: |
This PR includes automated updates from the scheduled workflow:
- Updated `firmware_releases.json` from the Meshtastic API (if changed).
- Updated `device_hardware.json` from the Meshtastic API (if changed).
- Source strings were uploaded to Crowdin.
- Latest translations were downloaded from Crowdin (if available).
- Updated module dependency graphs in README.md files (if changed).
Please review the changes.
branch: 'scheduled-updates'
base: 'main'
delete-branch: true
add-paths: |
app/src/main/assets/firmware_releases.json
app/src/main/assets/device_hardware.json
fastlane/metadata/android/**
**/strings.xml
**/README.md
labels: |
automation
l10n
firmware
hardware
check-workflow-status:
name: Check Workflow Status
runs-on: ubuntu-24.04-arm
permissions: {}
needs:
- update_assets
if: always()
steps:
- name: Check Workflow Status
if: "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')"
run: |
echo "One of the dependent jobs failed or was cancelled. Failing the workflow."
exit 1

View file

@ -1,26 +0,0 @@
name: Close Stale Issues
on:
schedule:
- cron: 0 6 * * *
workflow_dispatch: {}
permissions:
issues: write
pull-requests: write
actions: write
jobs:
stale_issues:
name: Close Stale Issues
runs-on: ubuntu-24.04-arm
if: github.repository == 'meshtastic/Meshtastic-Android'
steps:
- name: Stale PR+Issues
uses: actions/stale@v10.2.0
with:
days-before-stale: 30
stale-issue-message: This issue has not had any comment or update in the last 30 days. If it is still relevant, please post update comments. If no comments are made, this issue will be closed in 7 days.
exempt-issue-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies'
exempt-pr-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies'
operations-per-run: 100

58
.gitignore vendored
View file

@ -1,58 +1,16 @@
# Android Studio
/.idea/*
!/.idea/codeStyles
!/.idea/dictionaries/
!/.idea/runConfigurations/
!/.idea/icon.svg
*.iws
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/modules/app/google-services.json
.DS_Store .DS_Store
**/build/** /build
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
/app/release /app/release
/buildSrc/build
**/debug/**
**/release/**
# Java KeyStore certificates
*.jks
keystore.properties
# AGP profiling traces
*.trace
# Kotlin compiler
.kotlin
# VS code
.vscode/settings.json
# Secrets
/secrets.properties
/fastlane/play-store-credentials.json
**/google-services.json
# Generated library definitions
**/src/main/resources/aboutlibraries.json
/fastlane/report.xml
/build-logic/convention/build/*
/build-logic/build/
# Personal build scripts
build-and-install-android.sh
wireless-install.sh
# Git worktrees
.worktrees/
/firebase-debug.log.jdk/
firebase-debug.log
.agent_plans/
.agent_refs/
.agent_artifacts/

12
.gitmodules vendored
View file

@ -1,3 +1,9 @@
[submodule "app proto submodule"] [submodule "app/src/main/proto"]
path = core/proto/src/main/proto path = app/src/main/proto
url = https://github.com/meshtastic/protobufs.git url = https://github.com/meshtastic/Meshtastic-protobufs.git
[submodule "geeksville-androidlib"]
path = geeksville-androidlib
url = https://github.com/meshtastic/geeksville-androidlib.git
[submodule "design"]
path = design
url = https://github.com/meshtastic/meshtastic-design.git

1
.idea/.name generated Normal file
View file

@ -0,0 +1 @@
Mesh Util

View file

@ -1,41 +1,22 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<JavaCodeStyleSettings> <JetCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE"> <option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value> <value>
<package name="" withSubpackages="true" static="false" module="true" /> <package name="java.util" alias="false" withSubpackages="false" />
<package name="android" withSubpackages="true" static="true" /> <package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="androidx" withSubpackages="true" static="true" /> <package name="io.ktor" alias="false" withSubpackages="true" />
<package name="com" withSubpackages="true" static="true" /> </value>
<package name="junit" withSubpackages="true" static="true" /> </option>
<package name="net" withSubpackages="true" static="true" /> <option name="PACKAGES_IMPORT_LAYOUT">
<package name="org" withSubpackages="true" static="true" /> <value>
<package name="java" withSubpackages="true" static="true" /> <package name="" alias="false" withSubpackages="true" />
<package name="javax" withSubpackages="true" static="true" /> <package name="java" alias="false" withSubpackages="true" />
<package name="" withSubpackages="true" static="true" /> <package name="javax" alias="false" withSubpackages="true" />
<emptyLine /> <package name="kotlin" alias="false" withSubpackages="true" />
<package name="android" withSubpackages="true" static="false" /> <package name="" alias="true" withSubpackages="true" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value> </value>
</option> </option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<codeStyleSettings language="PROTO"> <codeStyleSettings language="PROTO">

10
.idea/compiler.xml generated Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11">
<module name="app" target="1.8" />
<module name="geeksville-androidlib" target="1.7" />
<module name="Mesh Util-geeksville-androidlib" target="1.7" />
</bytecodeTargetLevel>
</component>
</project>

6
.idea/encodings.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="PROJECT" charset="UTF-8" />
</component>
</project>

23
.idea/gradle.xml generated Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="delegatedBuild" value="false" />
<option name="testRunner" value="PLATFORM" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/geeksville-androidlib" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings>
</option>
</component>
</project>

16
.idea/icon.svg generated
View file

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="512" height="512" viewBox="0 0 512 512" xml:space="preserve">
<desc>Created with Fabric.js 4.6.0</desc>
<defs>
</defs>
<g transform="matrix(1 0 0 1 256 256)" id="xYQ9Gk9Jwpgj_HMOXB3F_" >
<path style="stroke: rgb(213,130,139); stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(103,234,148); fill-rule: nonzero; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-256, -256)" d="M 0 0 L 512 0 L 512 512 L 0 512 z" stroke-linecap="round" />
</g>
<g transform="matrix(1.79 0 0 1.79 313.74 258.36)" id="1xBsk2n9FZp60Rz1O-ceJ" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-250.97, -362.41)" d="M 250.908 330.267 L 193.126 415.005 L 180.938 406.694 L 244.802 313.037 C 246.174 311.024 248.453 309.819 250.889 309.816 C 253.326 309.814 255.606 311.015 256.982 313.026 L 320.994 406.536 L 308.821 414.869 L 250.908 330.267 Z" stroke-linecap="round" />
</g>
<g transform="matrix(1.81 0 0 1.81 145 256.15)" id="KxN7E9YpbyPgz0S4z4Cl6" >
<path style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: round; stroke-miterlimit: 2; fill: rgb(44,45,60); fill-rule: evenodd; opacity: 1;" vector-effect="non-scaling-stroke" transform=" translate(-115.14, -528.06)" d="M 87.642 581.398 L 154.757 482.977 L 142.638 474.713 L 75.523 573.134 L 87.642 581.398 Z" stroke-linecap="round" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,17 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

35
.idea/jarRepositories.xml generated Normal file
View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://api.mapbox.com/downloads/v2/releases/maven" />
</remote-repository>
</component>
</project>

9
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

6
.idea/render.experimental.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RenderSettings">
<option name="useLiveRendering" value="false" />
</component>
</project>

13
.idea/runConfigurations.xml generated Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.android.tools.idea.compose.preview.runconfiguration.ComposePreviewRunConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
</set>
</option>
</component>
</project>

13
.idea/saveactions_settings.xml generated Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SaveActionSettings">
<option name="actions">
<set>
<option value="activate" />
<option value="organizeImports" />
<option value="reformat" />
</set>
</option>
<option name="configurationPath" value="" />
</component>
</project>

9
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/proto" vcs="Git" />
<mapping directory="$PROJECT_DIR$/design" vcs="Git" />
<mapping directory="$PROJECT_DIR$/geeksville-androidlib" vcs="Git" />
</component>
</project>

1
.jdk
View file

@ -1 +0,0 @@
/home/james/.jdks/ms-17.0.18

View file

@ -1,295 +0,0 @@
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
new file mode 100644
index 0000000000..2a27b96906
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.common.di
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.ioDispatcher
+
+/**
+ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components.
+ *
+ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled
+ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not
+ * cancel siblings, and by [ioDispatcher] so work runs off the main thread.
+ *
+ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch
+ * and should be used sparingly.
+ */
+interface ApplicationCoroutineScope : CoroutineScope
+
+@Single(binds = [ApplicationCoroutineScope::class])
+internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope {
+ override val coroutineContext = SupervisorJob() + ioDispatcher
+}
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 231c84d401..5365ab95e2 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect
import co.touchlab.kermit.Logger
import com.eygraber.uri.toAndroidUri
import com.eygraber.uri.toKmpUri
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.common.util.ioDispatcher
import java.net.URLEncoder
@Composable
@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
val context = LocalContext.current
return remember(context) {
{ uri, maxChars ->
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
@Suppress("TooGenericExceptionCaught")
try {
val androidUri = uri.toAndroidUri()
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 031e1fe35d..a938f92ea6 100644
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.common.util.ioDispatcher
import java.awt.Desktop
import java.awt.FileDialog
import java.awt.Frame
@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
/** JVM — Reads text from a file URI. */
@Composable
actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars ->
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
@Suppress("TooGenericExceptionCaught")
try {
val file = File(URI(uri.toString()))
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index dc1c459716..f8ff9fcac8 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.database.entity.FirmwareRelease
@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel(
private val firmwareUpdateManager: FirmwareUpdateManager,
private val usbManager: FirmwareUsbManager,
private val fileHandler: FirmwareFileHandler,
+ private val applicationScope: ApplicationCoroutineScope,
) : ViewModel() {
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel(
override fun onCleared() {
super.onCleared()
- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a
- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a
- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope
- // is cancelled concurrently.
- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
- kotlinx.coroutines.GlobalScope.launch(NonCancellable) {
+ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
+ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup
+ // running even if something tries to cancel it mid-flight.
+ applicationScope.launch(NonCancellable) {
tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
}
}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
index 4c48a1ced5..030d84effd 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ TestApplicationCoroutineScope(testDispatcher),
)
@Test
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
index 7032ed4088..a8eddff838 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ TestApplicationCoroutineScope(testDispatcher),
)
@Test
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
new file mode 100644
index 0000000000..3ef5c44ef4
--- /dev/null
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.firmware
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import org.meshtastic.core.common.di.ApplicationCoroutineScope
+
+internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) :
+ ApplicationCoroutineScope,
+ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher)
diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
index acb1545bdd..23a0d03ab2 100644
--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ TestApplicationCoroutineScope(testDispatcher),
)
// -----------------------------------------------------------------------
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
index c251b4d5ef..315ad1da85 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_export_failed
import org.meshtastic.core.resources.debug_export_success
@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
}
private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: List<DebugViewModel.UiMeshLog>) =
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
try {
if (logs.isEmpty()) {
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") }
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
index 9afde85e5f..a28a576788 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
@Composable
actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit {
@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
return { fileName -> exportLauncher.launch(fileName) }
}
-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) {
+private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) {
try {
context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) }
Logger.i { "TAK data package exported successfully to $targetUri" }
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
index 5b63cc90a3..a9a7285593 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
return@launch
}
- withContext(Dispatchers.IO) {
+ withContext(ioDispatcher) {
// Run file dialog to ask user where to save
val fileDialog = FileDialog(null as Frame?, "Export Logs", FileDialog.SAVE)
fileDialog.file = fileName
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
index 9fb71379fc..bfbb85bc0d 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.tak
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import co.touchlab.kermit.Logger
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.meshtastic.core.common.util.ioDispatcher
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
@@ -44,7 +44,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
if (directory != null && file != null) {
val targetFile = File(directory, file)
val data = dataPackageProvider()
- withContext(Dispatchers.IO) { targetFile.writeBytes(data) }
+ withContext(ioDispatcher) { targetFile.writeBytes(data) }
Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" }
}
}

17
.project Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>Mesh Util</name>
<comment>Project MeshUtil created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
</projectDescription>

View file

@ -1 +0,0 @@
3.4.9

View file

@ -1,32 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Pre-Commit [spotlessApply detekt]" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="--no-parallel" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="spotlessApply" />
<option value="detekt" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<EXTENSION ID="com.android.tools.idea.testartifacts.testsuite.GradleRunConfigurationExtension">
<com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>false</com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>
<android.execution.deploysToLocalDevice>false</android.execution.deploysToLocalDevice>
</EXTENSION>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<GradleProfilingDisabled>false</GradleProfilingDisabled>
<GradleCoverageDisabled>false</GradleCoverageDisabled>
<method v="2" />
</configuration>
</component>

View file

@ -0,0 +1,13 @@
arguments=
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/usr/lib/jvm/java-8-oracle
jvm.arguments=
offline.mode=false
override.workspace.settings=true
show.console.view=true
show.executions.view=true

View file

@ -1,66 +0,0 @@
# Skill: Code Review
## Description
Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices.
## Code Review Checklist
When reviewing code, meticulously verify the following categories. Flag any deviations and propose the canonical project pattern as a fix.
### 1. KMP Architecture & Source Set Boundaries
- [ ] **No Platform Bleed:** Ensure absolutely no `java.*` or `android.*` imports exist in `commonMain` source sets.
- [ ] **KMP Native Alternatives:** Verify the use of KMP alternatives for standard JVM libraries:
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`
- `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`)
- `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` (purged from `commonMain`)
- [ ] **Coroutine Safety:** Use `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`).
- [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`.
- [ ] **File Naming Conflicts:** For `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.
- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities.
### 2. UI & Compose Multiplatform (CMP)
- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine.
- [ ] **String Formatting:** CMP only supports `%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).
- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`.
- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules.
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp).
### 3. Navigation & State
- [ ] **Shared Navigation Graphs:** Feature navigation graphs must be defined as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(...)`). Flag any graphs defined in platform-specific source sets.
- [ ] **Navigation Host:** Ensure `MeshtasticNavDisplay` (from `core:ui/commonMain`) is used as the host instead of invoking `NavDisplay` directly. Host modules should not configure `entryDecorators` themselves.
- [ ] **ViewModel Scoping:** ViewModels obtained via `koinViewModel()` must be inside `entry<T>` blocks to correctly tie to the backstack lifetime.
### 4. Dependency Injection (Koin Annotations)
- [ ] **Annotation Usage:** Ensure Koin is configured via annotations (`@Single`, `@Factory`, `@KoinViewModel`).
- [ ] **Root Assembly:** Confirm that the root Koin DI graph is only assembled in host shells (`app` and `desktop`).
### 5. Networking, DB & I/O
- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp.
- [ ] **HTTP Configuration:** Verify timeouts and base URLs use `HttpClientDefaults` from `core:network`. Never hardcode timeouts in feature modules. `DefaultRequest` sets the base URL; feature API services use relative paths.
- [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers.
- [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`.
- [ ] **Room Patterns:** Verify use of `@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.
- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions.
### 6. Dependency Catalog Aliases
- [ ] **JetBrains vs. AndroidX:**
- In `commonMain`: Must use `jetbrains-*` aliases (e.g., `jetbrains-lifecycle-*`, `jetbrains-navigation3-ui`).
- In `androidMain`: Can use `androidx-*` or `jetbrains-*` as appropriate, but do not mix them up in `commonMain`.
- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules.
### 7. Testing
- [ ] **Test Placement:** New Compose UI tests must go in `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.
- [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`.
- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking.
- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues.
### 8. ProGuard / R8 Rules
- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned.
- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed.
## Review Output Guidelines
1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern.
2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio").
3. **Enforce Build Health:** Remind authors to run `./gradlew test allTests` locally to verify changes, especially since KMP `test` tasks are ambiguous.
4. **Praise Good Patterns:** Acknowledge correct usage of complex architecture requirements, like proper Navigation 3 scene transitions or elegant `commonMain` helper extractions.

View file

@ -1,61 +0,0 @@
# Skill: Compose Multiplatform (CMP) UI
## Description
Guidelines for building shared UI, adaptive layouts, and handling strings/resources in Meshtastic-Android. The codebase uses Material 3 Adaptive.
## 1. UI Components & Layouts
- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktop/tablets.
- **Dialogs & Alerts:** Use centralized components like `AlertHost(alertManager)` from `core:ui/commonMain`. Do NOT trigger alerts inline or duplicate alert logic. Use `SharedDialogs(uiViewModel)` for general popups.
- **Placeholders:** Use `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features.
- **Theme Picker:** Use `ThemePickerDialog` from `feature:settings/commonMain`.
- **Platform Implementations:** Inject platform-specific behavior (e.g., Map providers) via `CompositionLocal` from the `app` or `desktop` shells. Do not tightly couple Google Maps/osmdroid dependencies to `commonMain`.
## 2. Strings & Resources
- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings.
- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context.
- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer).
- **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`):
```kotlin
val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5"
stringResource(Res.string.battery_percent, formatted) // uses %1$s
```
- **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings.
### String Formatting Decision Tree
Choose the right tool for the job:
| Scenario | Tool | Example |
|----------|------|---------|
| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)``"77.0°F"` |
| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` |
| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` |
| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` |
| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` |
| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` |
**Rules:**
1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats.
2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls.
3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`.
4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision.
- **Workflow to Add a String:**
1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`.
2. Use the generated `org.meshtastic.core.resources.<key>` symbol.
3. Validate UI presentation.
## 3. Tooling & Capabilities
- **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `app` and `desktop` host modules.
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code.
## 4. Compose Previews
- **Preview in commonMain:** CMP 1.11+ supports `@Preview` in `commonMain` via `compose-multiplatform-ui-tooling-preview`. Place preview functions alongside their composables.
- **Import:** Use `androidx.compose.ui.tooling.preview.Preview`. The JetBrains-prefixed import (`org.jetbrains.compose.ui.tooling.preview.Preview`) is deprecated.
## 5. Dialog & State Patterns
- **Dialog State Preservation:** Use `rememberSaveable` for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom `Saver` needed.
## Reference Anchors
- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml`
- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
- **Provider wiring:** `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt`

View file

@ -1,41 +0,0 @@
# Skill: Implement a Feature
## Description
A step-by-step workflow for implementing a new feature in the Meshtastic-Android codebase, ensuring KMP compatibility and proper architecture.
## Workflow
### 1. Update Dependencies & Aliases
- Check `gradle/libs.versions.toml` before adding libraries.
- Use `jetbrains-*` aliases for lifecycle/navigation/adaptive dependencies in `commonMain`.
- Use `compose-multiplatform-*` aliases for CMP dependencies.
### 2. Define the State & ViewModels
- Follow MVI/UDF patterns.
- Extend shared ViewModel logic in `feature/<name>/src/commonMain/kotlin/org/meshtastic/feature/<name>/<Name>ViewModel.kt`.
- Use `stateInWhileSubscribed` (from `core:ui`) for sharing state flows.
- Keep the ViewModel free of Android framework dependencies.
### 3. Build the UI
- Use Jetpack Compose Multiplatform (CMP).
- Define strings in `core:resources` (see the `compose-ui` skill).
- Support adaptive layouts (Large/XL breakpoints).
### 4. Wire Navigation & DI
- Define typed route objects in `core:navigation`.
- Export the navigation graph as an extension function on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.myFeatureGraph()`).
- Add the required DI bindings via Koin Annotations (`@Factory`, `@Single`, `@KoinViewModel`) in `commonMain`.
- **CRITICAL:** Ensure the module is registered in the app root graphs (`AppKoinModule.kt`, `DesktopKoinModule.kt`) and the navigation is injected into the root entry provider in the host shell.
### 5. Validate Platform Separation
- If you need a platform-specific API (like camera or specific mapping SDK), define an interface in `commonMain`, implement it in the host shell, and inject it via `CompositionLocal` or Koin.
### 6. Verify Locally
- Run the baseline checks (see `testing-ci` skill):
```bash
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
```
- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds:
```bash
./gradlew assembleFdroidRelease :desktop:runRelease
```

View file

@ -1,61 +0,0 @@
# Skill: KMP Architecture & Source-Set Bridging
## Description
Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstractions, networking, database, and platform integration rules.
## 1. Source-Set Boundaries
- **`commonMain`:** All business logic, DB entities, API network logic, ViewModels, and UI rendering. NO `java.*` or `android.*` imports.
- **`androidMain`:** Android framework integration (`Context`, system services, NFC hardware, BLE Android bindings).
- **`jvmMain` / `jvmAndroidMain`:** Shared JVM code between Android and Desktop. Uses the `meshtastic.kmp.jvm.android` convention plugin to bridge `jvm` and `android` source sets without manual `dependsOn` hacks.
- **`app` / `desktop`:** Host shells. Responsible for Koin DI root wiring, `MainKoinModule`, host-level UI themes, and running the `MeshtasticNavDisplay`.
## 2. Bridging Strategies
- **Interface + DI (Preferred):** Expose an interface in `core:repository` or `core:ui` (e.g. `LocationRepository`, `MapViewProvider`), implement it in `androidMain` or the host `app`, and bind it via Koin or `CompositionLocal`.
- **`expect`/`actual` (Restricted):** Use only when a platform API cannot be abstracted cleanly (e.g. low-level File I/O mappings, `uppercase()` Locale helpers). Avoid deep class hierarchies using `expect`/`actual`.
- **Naming:** Keep `expect` in `FileIo.kt`, but put shared helpers in `FileIoUtils.kt` to prevent JVM duplicate class errors.
- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper.
## 3. Core Libraries & Constraints
- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. Inject `CoroutineDispatchers` from `core:di` into classes that need dispatchers — never reference `Dispatchers.IO`/`Main`/`Default` directly in business logic.
- **Error Handling:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction loops).
- **Standard Library Replacements:**
- `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`.
- `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`.
- `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`).
- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging.
- **HTTP Configuration:** Use `HttpClientDefaults` from `core:network` for shared base URL (`API_BASE_URL`), timeouts, and retry constants. Both Android (`NetworkModule`) and Desktop (`DesktopKoinModule`) HttpClient instances must use these. Feature API services use relative paths; `DefaultRequest` sets the base URL.
- **BLE:** Route through `core:ble` using **Kable**.
- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`.
## 4. Hierarchy & Source-Set Conventions
- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model.
- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient.
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract to `commonMain`. Examples: `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BaseRadioTransportFactory`.
## 5. Dependency Catalog Aliases
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in `commonMain`.
- **Dependencies:** Always check `gradle/libs.versions.toml` before assuming a library is available.
## 6. I/O & Serialization
- **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
- **Room Patterns:**
- Use `@Upsert` for insert-or-update operations instead of manual `INSERT OR IGNORE` + `UPDATE` logic.
- Use `LIMIT 1` on `@Query` methods that expect a single row.
- Prevent N+1 queries: batch operations with `@Upsert fun putAll(items: List<T>)` or chunked `WHERE IN` queries (chunk size ≤ 999 to respect SQLite bind parameter limit).
## 7. Build-Logic Conventions
- In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
## 8. Onboarding a New Target (Desktop/iOS)
1. Ensure all new logic compiles against the KMP core (`jvm()`, `iosArm64()`, etc.).
2. Do not use platform-specific constructs in `commonMain` or you break the iOS/Desktop builds.
3. Test using `kmpSmokeCompile` to verify cross-platform compilation.
4. For desktop wiring, copy the pattern in `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` and use `NoopStubs.kt` to temporarily mock missing platform implementations.
## Reference Anchors
- **Shared Okio I/O:** `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt`
- **Desktop DI Stubs:** `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt`
- **Version Catalog:** `gradle/libs.versions.toml`
- **Convention Plugins:** `build-logic/convention/`

View file

@ -1,56 +0,0 @@
# Skill: DI and Navigation 3 Architecture
## Description
This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Navigation 3 (1.1.x) architecture, constraints, and anti-patterns within the Meshtastic-Android KMP codebase.
## Dependency Injection (Koin)
### Guidelines
1. **Annotations First:** Use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules to encapsulate dependency graphs per feature.
2. **App Root Assembly:** Don't assume feature/core `@Module` classes are active automatically. Ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/.../DesktopKoinModule.kt`.
3. **No Platform Bleed:** Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. Inject interfaces instead.
4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs.
### Anti-Patterns
- **A1 Module Compile Safety:** Do **not** enable `compileSafety`. It is a single boolean that enables A1 per-module checks — there is no separate A3 full-graph mode. Runtime graph verification is handled by `KoinVerificationTest` and `DesktopKoinTest` instead.
- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values.
### Koin Startup Pattern (K2 Compiler Plugin)
The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The canonical startup uses the plugin's typed `startKoin<T>()` stub, which the plugin transforms at compile time via IR:
```kotlin
// Bootstrap class — separate from @Module, references the root module graph
@KoinApplication(modules = [AppKoinModule::class])
object AndroidKoinApp
// In Application.onCreate()
startKoin<AndroidKoinApp> {
androidContext(this@MeshUtilApplication)
workManagerFactory()
}
```
- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class.
- `startKoin<T>()` (from `org.koin.plugin.module.dsl`) is a compiler plugin stub — if the plugin isn't applied, it throws `NotImplementedError`.
- `stopKoin()` uses the standard runtime API (`org.koin.core.context.stopKoin`).
- `compileSafety` must stay **disabled** — it enables A1 per-module checks that break our inverted-dependency architecture. There is no separate A3 full-graph flag.
## Navigation 3
### Guidelines
1. **Types:** Use Navigation 3 types consistently (`NavKey`, `NavBackStack`, `EntryProviderScope`).
2. **Typed Routes:** Keep route definitions in `core:navigation/src/commonMain/.../Routes.kt` as `@Serializable sealed interface` hierarchies. Don't use ad-hoc strings.
3. **Graph Assembly:** Define feature navigation graphs as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack)`).
4. **Host Integration:** Use `MeshtasticNavDisplay` (from `core:ui/commonMain`) as the Navigation 3 host. Do not configure decorators manually inside feature modules.
5. **Back Handlers:** Use `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures in multiplatform code. Do not use Android's `BackHandler`.
6. **Deep Links:** Use `DeepLinkRouter.route()` in `core:navigation` to synthesize typed backstacks from RESTful paths.
### Anti-Patterns
- **Single Backstack for Multiple Tabs:** Do **not** use a single `NavBackStack` list for multiple tabs. Use `MultiBackstack` (from `core:navigation`).
- **Decorator Reuse Across Tabs:** Do **not** reuse the same `NavEntryDecorator` instances across different backstacks. When rendering an active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance to prevent permanent `ViewModelStore` destruction.
- **Custom Backstack Mutation:** Do **not** mutate back navigation with custom stacks disconnected from the app backstack. Mutate `NavBackStack<NavKey>` directly with `add(...)` and `removeLastOrNull()`.
## Reference Anchors
- **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`
- **DI Bootstrap Object:** `app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt`
- **DI App Wiring:** `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`
- **Shared Routes:** `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
- **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`

View file

@ -1,79 +0,0 @@
# Skill: New Branch Bootstrap
## Description
Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill
whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh
branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work.
This replaces the ad-hoc prose that used to be retyped at the start of every session.
## When to Use
- Starting any new feature, fix, chore, or refactor.
- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)).
- Reproducing a CI failure from a clean baseline.
## Preconditions (verify before branching)
1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding.
2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at
`meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream.
3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md
workspace bootstrap rules.
4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties`
(required for `google` flavor builds).
## Standard Recipe
```bash
# 1. Fetch latest upstream
git fetch upstream --prune --tags
# 2. Create the branch from upstream/main (never from a local stale main)
git switch -c <branch-name> upstream/main
# 3. Ensure submodules track the new base
git submodule update --init --recursive
# 4. Sanity check
git --no-pager log -1 --oneline
```
## Branch Naming
Use conventional-commit style prefixes that match the PR title convention in AGENTS.md
`<git_and_prs>`:
| Prefix | Use for |
| :--- | :--- |
| `feat/<scope>` | New user-visible behavior |
| `fix/<scope>` | Bug fixes |
| `refactor/<scope>` | Code structure changes, no behavior change |
| `chore/<scope>` | Tooling, deps, CI, cleanup |
| `docs/<scope>` | Documentation only |
Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`.
## Rebase Variant
When the user says *"rebase #NNNN"* or *"dust off PR NNNN"*:
```bash
git fetch upstream --prune
gh pr checkout <NNNN> # checks out the PR head locally
git rebase upstream/main
git submodule update --init --recursive
# Resolve conflicts, then:
git push --force-with-lease
```
Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes.
## Post-Branch Checklist
- [ ] Branch name follows conventional prefix.
- [ ] Submodules up to date.
- [ ] `local.properties` exists.
- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap).
- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing.
## Tip: Prefer `/delegate` for Long Audits
If the user's opening prompt is a sweeping audit or investigation (e.g. *"audit changes since
v2.7.13 for regressions"*, *"investigate why animations are broken on release"*), consider
suggesting `/delegate` — the GitHub cloud agent can execute the branch + investigation + PR
end-to-end while the user keeps working locally. See AGENTS.md `<copilot_cli_workflow>`.

View file

@ -1,83 +0,0 @@
# Skill: Project Overview & Codebase Map
## Description
Module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android.
- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26.
- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics)
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`.
## Codebase Map
| Directory | Description |
| :--- | :--- |
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). |
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
| `docs/` | Architecture docs and agent playbooks. See `docs/kmp-status.md` and `docs/roadmap.md` for current status. |
| `core/model` | Domain models and common data structures. |
| `core:proto` | Protobuf definitions (Git submodule). |
| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
| `core:database` | Room KMP database implementation. |
| `core:datastore` | Multiplatform DataStore for preferences. |
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies. `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence. |
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
| `core:barcode` | Barcode scanning (Android-only). |
| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. |
| `core/ble/` | Bluetooth Low Energy stack using Kable. |
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
| `core/testing/` | Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules. |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. |
| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. |
| `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. |
## Namespacing
- **Standard:** Use the `org.meshtastic.*` namespace for all code.
- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
## Environment Setup
1. **JDK 21 MUST be used** to prevent Gradle sync/build failures.
2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`:
```properties
MAPS_API_KEY=dummy_key
datadogApplicationId=dummy_id
datadogClientToken=dummy_token
```
## Workspace Bootstrap (MUST run before any build)
Agents **MUST** perform these steps automatically at the start of every session before running any Gradle task. Do not wait for the user to tell you.
1. **Android SDK:** `ANDROID_HOME` may not be set in agent workspaces. Detect and export it:
```bash
# Check common macOS/Linux locations in order of preference
if [ -z "$ANDROID_HOME" ]; then
for dir in "$HOME/Library/Android/sdk" "$HOME/Android/Sdk" "/opt/android-sdk"; do
if [ -d "$dir" ]; then export ANDROID_HOME="$dir"; break; fi
done
fi
```
All `./gradlew` invocations must include `ANDROID_HOME` in the environment. If the SDK cannot be found, ask the user for the path.
2. **Proto submodule:** `core/proto/src/main/proto` is a Git submodule containing Protobuf definitions. It must be initialized or builds will fail with proto generation errors:
```bash
git submodule update --init
```
3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails:
```bash
[ -f local.properties ] || cp secrets.defaults.properties local.properties
```
## Troubleshooting
- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist.
- **Koin Injection Failures:** Verify the component is included in `AppKoinModule`.

View file

@ -1,85 +0,0 @@
# Skill: Testing and CI Verification
## Description
Guidelines and commands for verifying code changes locally and understanding the Meshtastic-Android CI pipeline. Use this to determine which testing matrix is needed based on the change type.
## 1) Baseline local verification order
Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation:
```bash
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
```
> **Why no `clean`?** Incremental builds are safe and significantly faster. Only use `clean` when debugging stale cache issues.
> **Why `test allTests` and not just `test`:**
> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP plugin.
> Conversely, `allTests` does **not** cover pure-Android modules (`:app`, `:core:api`, etc.), which is why both `test` and `allTests` are needed.
*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
## 2) Change-type verification matrix
- `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical.
- `UI text/resource` changes: `spotlessCheck`, `detekt`, `assembleDebug`.
- `feature/commonMain logic` changes: `spotlessCheck`, `detekt`, `test allTests`, `assembleDebug`.
- `navigation/DI wiring` changes: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`, plus flavor unit tests if available.
- If touching any KMP module, also run `kmpSmokeCompile`.
- `worker/service/background` changes: Broad tests, targeted WorkManager checks.
- `BLE/networking/core repository`: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`.
## 3) Flavor checks
Run these when relevant to map, provider, or flavor-specific behavior:
```bash
./gradlew lintFdroidDebug lintGoogleDebug
./gradlew testFdroidDebug testGoogleDebug
```
## 4) CI Pipeline Architecture
CI is defined in `.github/workflows/reusable-check.yml` and structured as four parallel job groups:
1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation (avoids 3x cold-start overhead). Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs.
2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`):
- `shard-core`: `allTests` for all `core:*` KMP modules.
- `shard-feature`: `allTests` for all `feature:*` KMP modules.
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`).
Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags.
Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones.
3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`).
4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds desktop distributions via `createDistributable` (depends on `lint-check`).
### Runner Strategy (Three Tiers)
- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). Benefits from ARM runners' shorter queue times.
- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, publish, dependency-submission). Pin for reproducibility.
- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job and release packaging.
### CI Gradle Properties
`gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties`. Key CI overrides:
- `org.gradle.daemon=false` (single-use runners)
- `kotlin.incremental=false` (fresh checkouts)
- `-Xmx4g` Gradle heap, `-Xmx2g` 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 auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks.
- **`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`.
- **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-{version}-sdk{level}` — update when bumping version or SDK level.
- **`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-retry` plugin:** Applied to all module types (maxRetries=2, maxFailures=10).
- **`fail-fast: false`:** Test sharding does not cancel other shards on failure.
- **Explicit Gradle task paths:** Prefer `app:lintFdroidDebug` over shorthand `lintDebug` in CI.
- **Pull request CI:** Main-only (`.github/workflows/pull-request.yml` targets `main`).
- **Cache writes:** Trusted on `main` and merge queue runs; other refs use read-only cache.
- **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.).
- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone.

108
AGENTS.md
View file

@ -1,108 +0,0 @@
# Meshtastic Android - Unified Agent & Developer Guide
<role>
You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Meshtastic-Android, a decentralized mesh networking application. You must maintain strict architectural boundaries, use Modern Android Development (MAD) standards, and adhere to Compose Multiplatform and JetBrains Navigation 3 patterns.
</role>
<context_and_memory>
- **Project Goal:** Decouple business logic from the Android framework for seamless multi-platform execution (Android, Desktop, iOS) while maintaining a high-performance native Android experience.
- **Language & Tech:** Kotlin 2.3+ (JDK 21 REQUIRED), Gradle Kotlin DSL, Ktor, Okio, Room KMP.
- **Core Architecture:**
- `commonMain` is pure KMP. `androidMain` is strictly for Android framework bindings.
- App root DI and graph assembly live in the `app` and `desktop` host shells.
- **Skills Directory:** You **MUST** consult the relevant `.skills/` module before executing work:
- `.skills/project-overview/` - Codebase map, module directory, namespacing, environment setup, troubleshooting.
- `.skills/kmp-architecture/` - Bridging, expect/actual, source-sets, catalog aliases, build-logic conventions.
- `.skills/compose-ui/` - Adaptive UI, placeholders, string resources.
- `.skills/navigation-and-di/` - JetBrains Navigation 3 & Koin 4.2+ annotations.
- `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties.
- `.skills/implement-feature/` - Step-by-step feature workflow.
- `.skills/code-review/` - PR validation checklist.
- `.skills/new-branch/` - Canonical recipe for branching off upstream/main and rebasing stale PRs.
- **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch.
</context_and_memory>
<process>
- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically:
1. **Find the Android SDK**`ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user.
2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds.
3. **Init secrets** — If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails.
- **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing.
- **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code.
- **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first).
- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing:
```
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
```
> **Why both `test` and `allTests`?** In KMP modules, `test` is ambiguous and Gradle silently skips them. `allTests` is the KMP lifecycle task that covers KMP modules. Conversely, `allTests` does NOT cover pure-Android modules (`:app`, `:core:api`), so both tasks are required.
> For KMP cross-platform compilation, also run `./gradlew kmpSmokeCompile` (compiles all KMP modules for JVM + iOS Simulator — used by CI's `lint-check` job).
</process>
<agent_tools>
- **Codebase Search:** Use whatever search and navigation tools your environment provides (file search, grep/ripgrep, symbol lookup, semantic search, etc.) to map out project boundaries before coding. Prefer `rg` (ripgrep) over `grep` or `find` for raw text search.
- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent getting stuck in an interactive prompt.
- **Fetch Up-to-Date Docs:** If your environment supports web search, MCP servers, or documentation lookup tools, actively query them for the latest documentation on Koin 4.x, JetBrains Navigation 3, and Compose Multiplatform 1.11.
- **Clone Reference Repos:** If documentation is insufficient, use shell commands to clone bleeding-edge KMP dependency repositories into the local `.agent_refs/` directory (git-ignored) to inspect their source and test suites. Recommended:
- `https://github.com/JetBrains/kotlin-multiplatform-dev-docs` (Official Docs)
- `https://github.com/InsertKoinIO/koin` (Koin Annotations 4.x)
- `https://github.com/JetBrains/compose-multiplatform` (Navigation 3, Adaptive UI)
- `https://github.com/JuulLabs/kable` (BLE)
- `https://github.com/coil-kt/coil` (Coil 3 KMP)
- `https://github.com/ktorio/ktor` (Ktor Networking)
- **Formatting Hooks:** Always run `./gradlew spotlessApply` as an automatic formatting hook to fix style violations after editing.
</agent_tools>
<documentation_sync>
`AGENTS.md` is the single source of truth for agent instructions. Agent-specific files redirect here:
- `.github/copilot-instructions.md` — Copilot redirect to `AGENTS.md`.
- `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions.
- `GEMINI.md` — Gemini redirect to `AGENTS.md`. Gemini CLI also configured via `.gemini/settings.json` to read `AGENTS.md` directly.
Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, and `docs/kmp-status.md` as needed.
</documentation_sync>
<rules>
- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application.
- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. Use KMP equivalents: `Okio` for `java.io.*`, `kotlinx.coroutines.sync.Mutex` for `java.util.concurrent.locks.*`, `atomicfu` or Mutex-guarded `mutableMapOf()` for `ConcurrentHashMap`. Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO` directly.
- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety — A3 full-graph validation (`VerifyModule`) is the correct approach because interfaces and implementations live in separate modules. Always register new feature modules in **both** `AppKoinModule.kt` and `DesktopKoinModule.kt`; they are not auto-activated.
- **CMP Over Android:** Use `compose-multiplatform` constraints. `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()` from `core:common` and pass as `%N$s`. In ViewModels/coroutines use `getStringSuspend(Res.string.key)`; never blocking `getString()`. Always use `MeshtasticNavDisplay` (not raw `NavDisplay`) as the navigation host, and `NavigationBackHandler` (not Android's `BackHandler`) for back gestures in shared code.
- **ProGuard:** When adding a reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro` and verify release builds.
- **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist.
- **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution.
- **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them.
- **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules.
- **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change.
</rules>
<copilot_cli_workflow>
These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this
section.
- **Delegate long autonomous work.** For sweeping audits, multi-hour investigations, or "fleet"
prompts (*"investigate why X is broken on release"*, *"audit the diff since tag vX.Y.Z"*,
*"review the codebase for best practices against spec Z"*), prefer `/delegate` so the GitHub
cloud agent opens a PR while the user keeps working locally. Don't tie up an interactive
session on work that can run unattended.
- **Use `/research` for "latest hotness" prompts.** When the user asks for *"the latest scoop"*
on Kotlin / KMP / Compose / Koin trends, the built-in `/research` slash command performs deep
research across GitHub and the web with better source grounding than an ad-hoc prompt.
- **Use `/plan` mode for "noodle it out" prompts.** When the user asks for an implementation
plan, a "walk me through next steps", or explicitly says "don't do anything yet" — switch to
plan mode (Shift+Tab or `/plan`). Plans persist in the session workspace and keep the agent
from prematurely editing files. Continue to write long-form plans and Mermaid diagrams to
`.agent_plans/` (git-ignored) for multi-module refactors.
- **`/share` audit and review outputs.** After large audits, PR safety reviews, or release-cycle
quality passes, offer `/share` to export the findings to a gist or markdown file. These
reports are valuable artifacts — don't let them die in session history.
- **Prefer `/rewind` or `ctrl+s` over retyping.** If a turn went sideways, `/rewind` reverts
file changes and the turn; `ctrl+s` submits while preserving the input for quick iteration.
Avoid re-issuing the same prompt verbatim.
- **New-branch flow lives in a skill.** When the user says "fresh branch off fetched origin/main"
or "rebase PR #NNNN", consult `.skills/new-branch/SKILL.md` rather than re-deriving the recipe.
</copilot_cli_workflow>
<git_and_prs>
- **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation.
- **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style.
- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters.
</git_and_prs>

View file

@ -1,9 +0,0 @@
# Meshtastic Android - Claude Code Guide
@AGENTS.md
## Claude-Specific Instructions
- **Think First:** Always outline your step-by-step reasoning inside `<thinking>` tags before writing code or shell commands. Claude models perform significantly better on complex KMP tasks when they "think out loud" first.
- **Skills:** The `.skills/` directory contains task-specific instruction modules. Load them as needed — only the skill relevant to your current task.
- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). The Copilot-CLI-specific `/plan`, `/delegate`, `/research`, and `/share` guidance in `AGENTS.md` does not apply to Claude Code — skip the `<copilot_cli_workflow>` section.

View file

@ -1,4 +0,0 @@
# Contributor Covenant Code of Conduct
The Meshtastic Firmware project is subject to the code of conduct for the parent project, which can be found here:
https://meshtastic.org/docs/legal/conduct/

View file

@ -1,94 +0,0 @@
# Contributing to Meshtastic-Android
Thank you for your interest in contributing to Meshtastic-Android! We welcome contributions from everyone. Please take a moment to review these guidelines to help us maintain a high-quality, collaborative project.
## How to Contribute
- **Fork the repository** and create your branch from `main` or the appropriate feature branch.
- **Make your changes** in a logical, atomic manner.
- **Test your changes** thoroughly before submitting a pull request.
- **Submit a pull request** (PR) with a clear description of your changes and the problem they solve.
- If you are addressing an existing issue, please reference it in your PR (e.g., `Fixes #123`).
## Code Style
- Follow the [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html) for Kotlin code.
- Use Android Studio's default formatting settings.
- We use [spotless](https://github.com/diffplug/spotless) for automated code formatting. You can run `./gradlew spotlessApply` to format your code automatically.
- You can also run `./gradlew spotlessInstallGitPrePushHook --no-configuration-cache` to install a pre-push Git hook that will run a `spotlessCheck`.
- Write clear, descriptive variable and function names.
- Add comments where necessary, especially for complex logic.
- Keep methods and classes focused and concise.
- **Strings:** Use localised strings via the **Compose Multiplatform Resource** library in `:core:resources`.
- Do **not** use the legacy `app/src/main/res/values/strings.xml`.
- **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Usage:**
```kotlin
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.your_string_key
Text(text = stringResource(Res.string.your_string_key))
```
### Linting
Meshtastic-Android uses [Detekt](https://detekt.dev/) for static code analysis and linting of Kotlin code.
- Run `./gradlew detekt` before submitting your pull request to ensure your code passes all lint checks.
- Fix any Detekt warnings or errors reported in your code.
- It is possible to suppress warnings individually, but this should be used very sparingly.
- You can find Detekt configuration in the `config/detekt` directory. If you believe a rule should be changed or suppressed, discuss it in your PR.
Consistent linting helps keep the codebase clean and maintainable.
### Testing
Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI tests to ensure code quality and reliability.
- **Unit tests** are located in the `src/test/` directory of each module.
- **Compose UI Tests (JVM)** are preferred for component testing and are also located in `src/test/` using **Robolectric**.
- Note: If using Java 21, pin your Robolectric tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility issues.
- **Instrumented tests** (including full E2E UI tests) are located in `src/androidTest/`. For Compose UI, use the [Jetpack Compose Testing APIs](https://developer.android.com/jetpack/compose/testing).
#### Guidelines for Testing
- Add or update tests for any new features or bug fixes.
- Ensure all tests pass by running:
- `./gradlew test` for unit and Robolectric tests
- `./gradlew connectedAndroidTest` for instrumented tests
- For UI components, write Robolectric Compose tests where possible for faster execution.
- If your change is difficult to test, explain why in your pull request.
Comprehensive testing helps prevent regressions and ensures a stable experience for all users.
## Pull Requests
- branches should start with:
- bugfix
- enhancement
- dependencies
- repo
- reserved (release, automation)
- Ensure your branch is up to date with the latest `main` branch before submitting a PR.
- Provide a meaningful title and description for your PR.
- Include information on how to test and/or replicate if it is not obvious.
- Include screenshots or logs if your change affects the UI or user experience.
- Be responsive to feedback and make requested changes promptly.
- Squash commits if requested by a maintainer.
## Issue Reporting
- Search existing issues before opening a new one to avoid duplicates.
- Provide a clear and descriptive title.
- Include steps to reproduce, expected behavior, and actual behavior.
- Attach logs, screenshots, or other helpful context if applicable.
## Community Standards
- Be respectful and considerate in all interactions.
- The Meshtastic Android project is subject to the code of conduct for the parent project, which can be [found here:](https://meshtastic.org/docs/legal/conduct/)
- Help others by reviewing pull requests and answering questions when possible.
Thank you for helping make Meshtastic-Android better!

View file

@ -1,6 +0,0 @@
# Meshtastic Android - Google Gemini Guide
> **Note:** The canonical instructions for all AI Agents have been deduplicated.
You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`.
After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.

View file

@ -1,3 +0,0 @@
source "https://rubygems.org"
gem "fastlane"

View file

@ -1,238 +0,0 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1240.0)
aws-sdk-core (3.245.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.219.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.233.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2.0)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.99.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.4)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.20.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (7.0.5)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin-24
ruby
DEPENDENCIES
fastlane
BUNDLED WITH
2.7.2

160
README.md
View file

@ -1,106 +1,90 @@
<p align="center"> # Meshtastic-Android
<img src=".github/meshtastic_logo.png" alt="Meshtastic Logo" width="200"/>
</p>
<h1 align="center">Meshtastic-Android</h1>
![GitHub all releases](https://img.shields.io/github/downloads/meshtastic/meshtastic-android/total) ![Android CI](https://github.com/meshtastic/Meshtastic-Android/workflows/Android%20CI/badge.svg?branch=master)
[![Android CI](https://github.com/meshtastic/Meshtastic-Android/actions/workflows/pull-request.yml/badge.svg?branch=main)](https://github.com/meshtastic/Meshtastic-Android/actions/workflows/pull-request.yml)
[![codecov](https://codecov.io/gh/meshtastic/Meshtastic-Android/graph/badge.svg)](https://codecov.io/gh/meshtastic/Meshtastic-Android)
[![Crowdin](https://badges.crowdin.net/e/f440f1a5e094a5858dd86deb1adfe83d/localized.svg)](https://crowdin.meshtastic.org/android)
[![CLA assistant](https://cla-assistant.io/readme/badge/meshtastic/Meshtastic-Android)](https://cla-assistant.io/meshtastic/Meshtastic-Android)
[![Fiscal Contributors](https://opencollective.com/meshtastic/tiers/badge.svg?label=Fiscal%20Contributors&color=deeppink)](https://opencollective.com/meshtastic/)
[![Vercel](https://img.shields.io/static/v1?label=Powered%20by&message=Vercel&style=flat&logo=vercel&color=000000)](https://vercel.com?utm_source=meshtastic&utm_campaign=oss)
This is a tool for using Android (and Compose Desktop) with open-source mesh radios. For more information see our webpage: [meshtastic.org](https://www.meshtastic.org). If you are looking for the device side code, see [here](https://github.com/meshtastic/firmware). This is a tool for using Android with open-source mesh radios. For more information see our webpage: [meshtastic.org](https://www.meshtastic.org). If you are looking for the the device side code, see [here](https://github.com/meshtastic/Meshtastic-esp32).
This project is currently beta testing across various providers. If you have questions or feedback please [Join our discussion forum](https://github.com/orgs/meshtastic/discussions) or the [Discord Group](https://discord.gg/meshtastic) . We would love to hear from you! This project is currently beta testing, if you have questions or feedback please [Join our discussion forum](https://meshtastic.discourse.group/). We would love to hear from you.
The production version of our application is here:
[![Download at https://play.google.com/store/apps/details?id=com.geeksville.mesh](https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png)](https://play.google.com/store/apps/details?id=com.geeksville.mesh&referrer=utm_source%3Dgithub-android-readme)
## Get Meshtastic But if you want the beta-test app now, we'd love to have your help testing. Three steps to opt-in to the test:
The easiest, and fastest way to get the latest beta releases is to use our [github releases](https://github.com/meshtastic/Meshtastic-Android/releases). It is recommend to use these with [Obtainium](https://github.com/ImranR98/Obtainium) to get the latest updates. 1. Join [this Google group](https://groups.google.com/forum/#!forum/meshtastic-alpha-testers) with the account you use in Google Play. **Optional** - if you just want 'beta builds'
not bleeding edge alpha test builds skip to the next step.
2. Go to this [URL](https://play.google.com/apps/testing/com.geeksville.mesh) to opt-in to the alpha/beta test.
3. If you encounter any problems or have questions, [post in the forum](https://meshtastic.discourse.group/)t and we'll help.
Alternatively, these other providers are also available, but may be slower to update. The app is also distributed for Amazon Fire devices via the Amazon appstore: [![Amazon appstore link](https://raw.githubusercontent.com/meshtastic/Meshtastic-device/master/images/amazon-fire-button.png)](https://www.amazon.com/Geeksville-Industries-Meshtastic/dp/B08CY9394Q)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" ## Build instructions
alt="Get it on F-Droid"
width="24%">](https://f-droid.org/packages/com.geeksville.mesh/)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
alt="Get it on IzzyOnDroid"
width="24%">](https://apt.izzysoft.de/fdroid/index/apk/com.geeksville.mesh)
[<img src="https://github.com/machiav3lli/oandbackupx/blob/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png"
alt="Get it on GitHub"
width="24%">](https://github.com/meshtastic/Meshtastic-Android/releases)
[<img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"
alt="Download at https://play.google.com/store/apps/details?id=com.geeksville.mesh]"
width="24%">](https://play.google.com/store/apps/details?id=com.geeksville.mesh&referrer=utm_source%3Dgithub-android-readme)
The play store is the last to update of these options, but if you want to join the Play Store testing program go to [this URL](https://play.google.com/apps/testing/com.geeksville.mesh) and opt-in to become a tester. If you would like to develop this application we'd love your help! These build instructions are brief
If you encounter any problems or have questions, [ask us on the discord](https://discord.gg/meshtastic), [create an issue](https://github.com/meshtastic/Meshtastic-Android/issues), or [post in the forum](https://github.com/orgs/meshtastic/discussions) and we'll help as we can. and should be improved, please send a PR if you can.
## Documentation * Use Android Studio 4.1.2 to build/debug (other versions might work but no promises)
* Use "git submodule update --init --recursive" to pull in the various submodules we depend on
* There are a few config files which you'll need to copy from templates included in the project.
Run the following commands to do so:
The project's documentation is generated with [Dokka](https://kotlinlang.org/docs/dokka-introduction.html) and hosted on GitHub Pages. It is automatically updated on every push to the `main` branch. ```
rm ./app/google-services.json
[**View Documentation**](https://meshtastic.github.io/Meshtastic-Android/) cp ./app/google-services-example.json ./app/google-services.json
rm ./app/src/main/res/values/mapbox-token.xml
### Generating Locally cp ./app/special/mapbox-token.xml ./app/src/main/res/values/
rm ./app/src/main/res/values/curfirmwareversion.xml
You can generate the documentation locally to preview your changes. cp ./app/special/curfirmwareversion.xml ./app/src/main/res/values/
1. **Run the Dokka task:**
```bash
./gradlew dokkaGeneratePublicationHtml
```
2. **View the output:**
The generated HTML files will be located in the `build/dokka/html` directory. You can open the `index.html` file in your browser to view the documentation.
## Architecture
### Modern Android Development (MAD)
The app follows modern Android development practices, built on top of a shared Kotlin Multiplatform (KMP) Core:
- **KMP Modules:** Business logic (`core:domain`), data sources (`core:data`, `core:database`, `core:datastore`), and communications (`core:network`, `core:ble`) are entirely platform-agnostic, targeting Android and Compose Desktop.
- **UI:** JetBrains Compose Multiplatform (Material 3) using Compose Multiplatform resources.
- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
- **Dependency Injection:** Koin with Koin Annotations (K2 Compiler Plugin).
- **Navigation:** JetBrains Navigation 3 (Multiplatform routing with RESTful deep linking).
- **Data Layer:** Repository pattern with Room KMP (local DB), DataStore (prefs), and Protobuf (device comms).
### Bluetooth Low Energy (BLE)
The BLE stack uses a multiplatform interface-driven architecture. Platform-agnostic interfaces live in `commonMain`, utilizing the **Kable** multiplatform BLE library to handle device communication across all supported targets (Android, Desktop). This provides a robust, Coroutine-based architecture for reliable device communication while remaining fully KMP compatible. See [core/ble/README.md](core/ble/README.md) for details.
## Translations
You can help translate the app into your native language using [Crowdin](https://crowdin.meshtastic.org/android).
## API & Integration
Developers can integrate with the Meshtastic Android app using our published API library via **JitPack**. This allows third-party applications (like the ATAK plugin) to communicate with the mesh service via AIDL.
For detailed integration instructions, see [core/api/README.md](core/api/README.md).
Additionally, the app includes a built-in **Local TAK Server** feature that can be enabled in settings. This runs a local TCP server on port 8089 to allow ATAK clients to connect directly and route their traffic over the mesh.
## Building the Android App
> [!WARNING]
> Debug and release builds can be installed concurrently. This is solely to enable smoother development, and you should avoid running both apps simultaneously. To ensure proper function, force quit the app not in use.
https://meshtastic.org/docs/development/android/
Note: when building the `google` flavor locally you will need to supply your own [Google Maps Android SDK api key](https://developers.google.com/maps/documentation/android-sdk/get-api-key) `MAPS_API_KEY` in `local.properties` in order to use Google Maps.
e.g.
```properties
MAPS_API_KEY=your_google_maps_api_key_here
``` ```
## Contributing guidelines * (unfortunately) you need to get a (free) mapbox developer token [here](https://docs.mapbox.com/android/maps/guides/install/) and put that token in your user gradle.properties.
```
~/development/meshtastic/MeshUtil$ cat ~/.gradle/gradle.properties
MAPBOX_DOWNLOADS_TOKEN=sk.yourtokenherexxx
```
For detailed instructions on how to contribute, please see our [CONTRIBUTING.md](CONTRIBUTING.md) file. * Now you should be able to select "Run / Run" in the IDE and it will happily start running on your phone
For details on our release process, see the [RELEASE_PROCESS.md](RELEASE_PROCESS.md) file. or the emulator. Note: The emulators don't support bluetooth, so some features can not be used in
that environment.
## Repository Statistics ## Analytics setup
* analytics are included but can be disabled by the user on the settings screen
* on dev devices
```shell
adb shell setprop debug.firebase.analytics.app com.geeksville.mesh
adb shell setprop log.tag.FirebaseCrashlytics DEBUG
```
for verbose logging:
```shell
adb shell setprop log.tag.FA VERBOSE
```
## Publishing to google play
(Only supported if you are a core developer that needs to do releases)
# Credits
This project is the work of volunteers:
* @artemisoftnian: Contributed Spanish translations.
* @CycloMies: Contributed Swedish, Finnish and German translations.
* @eriktheV-king: Contributed Dutch and French translations.
* @Lgoix: Contributed tooltip for radio mode
* @Eugene: Contributed Russian translation.
* @Jinx17: Contributed Slovenian translation.
* @Konradrundfunk: Contributed German fixes.
* @Mango-sauce: Contributed Chinese translation.
* @NEKLAN: Contributed Japanese translation.
* @Ohcdh: Contributed Irish and Italian translations.
* @Slavino: Contributed Slovak translation.
* @Zone: Contributed Turkish translation.
Copyright 2019, Geeksville Industries, LLC. GPL V3 license
![Alt](https://repobeats.axiom.co/api/embed/1d75239069a6d671fe0b8f80b2e1bf590a98f0eb.svg "Repobeats analytics image")
Copyright 2025, Meshtastic LLC. GPL-3.0 license

View file

@ -1,65 +0,0 @@
# Meshtastic-Android Release Process
This guide summarizes the steps for releasing a new version of Meshtastic-Android. The process is fully automated via GitHub Actions and Fastlane.
## Overview
The entire release process is managed by a single, manually-triggered GitHub Action: **`Create or Promote Release`**.
- **Trigger:** To start a new release or promote an existing one, a developer manually runs the workflow from the GitHub Actions tab.
- **Inputs:** The workflow requires two inputs:
1. `version`: The base version number you are releasing (e.g., `2.4.0`).
2. `channel`: The release channel you are targeting (`internal`, `closed`, `open`, or `production`).
- **Automation:** The workflow handles everything automatically:
- **Syncs Assets:** Fetches the latest firmware/hardware lists, protobuf definitions, and translations (Crowdin).
- **Generates Changelog:** Creates a clean changelog from commits since the last production release and commits it to the repo.
- **Updates Config:** Automatically bumps the `VERSION_NAME_BASE` in `config.properties`.
- **Verifies & Tags:** Runs lint checks, builds the app, and *only* tags the release if successful.
- **Deploys:** Uploads the build to the correct Google Play track and attaches artifacts (`.aab`/`.apk`) to a GitHub Release.
- **Changelog:** Release notes are auto-generated from PR labels. Ensure PRs are labeled correctly to maintain an accurate changelog.
## Release Steps
### 1. Start an Internal Release
1. Navigate to the **Actions** tab in the GitHub repository.
2. Select the **`Create or Promote Release`** workflow.
3. Click the **"Run workflow"** dropdown.
4. Enter the base `version` (e.g., `2.4.0`).
5. Select the `internal` channel.
6. Click **"Run workflow"**.
The workflow will:
1. **Create a new commit** on the current branch containing updated assets, translations, and the new changelog.
2. **Tag** that commit with an incremental internal tag (e.g., `v2.4.0-internal.1`).
3. **Build & Deploy** the verified artifact to the Play Store Internal track.
4. Publish a **draft** pre-release on GitHub.
### 2. Promote to the Next Channel
Once an internal build has been verified, you can promote it to a wider audience.
1. Run the **`Create or Promote Release`** workflow again with the same base `version`.
2. Select the next channel in the sequence (e.g., `closed`, then `open`).
3. The workflow will create a new incremental tag for that channel (e.g., `v2.4.0-closed.1`) and create a **published** pre-release on GitHub.
### 3. Promote to Production
After testing is complete on all pre-release channels, you can create the final public release.
1. Run the **`Create or Promote Release`** workflow one last time.
2. Use the same base `version`.
3. Select the `production` channel.
4. The workflow will create a clean version tag (e.g., `v2.4.0`) and create a **published, stable** (non-prerelease) release on GitHub.
### 4. Post-Release
1. **Verify:** Check the Google Play Console to ensure the build is available on the correct track.
2. **Merge:** Merge the release branch (if one was used for stabilization) back into `main`.
## Build Attestations & Provenance
All release artifacts are accompanied by explicit GitHub build attestations (provenance). This provides cryptographic proof that the artifacts were built by our trusted GitHub Actions workflow, ensuring supply chain integrity.
- You can view and verify provenance in the GitHub UI under each release asset.
- For more details, see [GitHub's documentation on build provenance](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#provenance-attestations).

View file

@ -1,12 +0,0 @@
# Security Policy
## Supported Versions
| App Version | Supported |
| ---------------- | ------------------ |
| 2.7.x | :white_check_mark: |
| <= 2.6.x | :x: |
## Reporting a Vulnerability
We support the private reporting of potential security vulnerabilities. Please go to the Security tab to file a report with a description of the potential vulnerability and reproduction scripts (preferred) or steps, and our developers will review.

51
TODO.md Normal file
View file

@ -0,0 +1,51 @@
# Misc todo
* move analytics out with https://blog.gradle.org/optional-dependencies
# Documentation tasks
Mostly for geeksville
Document the following in application behavior
*change ls_secs is 1 hr normally, which is fine because if there are other nodes in the mesh and they send us a packet we will wake any time during ls_secs and update app state
* use states for meshservice: disconnected -> connected-> devsleep -> disconnected (3 states)
* when device enters LS state radiointerfaceservice publishes "Broadcasting connection=false", meshservice should then enter devicesleepstate for ls_secs + 30s (to allow for some margin)
* describe user experience: devices always point to each other and show distance, you can send texts between nodes
the channel is encrypted, you can share the the channel key with others by qr code or by sharing a special link
* take video of the app
# Post 1.0
* show pointer arrow on the outside of the user icons, always pointing towards them
* Use setLargeIcon to show user icons in the notification: file:///home/kevinh/packages/android-sdk-linux/docs/design/patterns/notifications.html
* Our notification about messages should use VISIBLITY_PRIVATE + setPublicVersion per file:///home/kevinh/packages/android-sdk-linux/docs/guide/topics/ui/notifiers/notifications.html
* Use LocationRequest.setSmallestDisplacement to save battery and decrease net activity
* use platform theme (dark or light)
* Do PRIORITY_BALANCED_POWER_ACCURACY for our gps updates when no one in the mesh is nearer than 200 meters
* spend some quality power consumption tuning with https://developer.android.com/studio/profile/energy-profiler and https://developer.android.com/topic/performance/power/battery-historian
* use google signin to get user name (make optional)
* keep past messages in db, one db per channel (currently we just keep an array in saved preferences)
* show user avatars in chat (use the google user info api)
* let users save old channels (i.e. have a menu of channels the user can use)
* if the rxpacket queue on the device overflows (because android hasn't connected in a while) send a special packet to android which means 'X packets have been dropped because you were offline' -drop oldest packets first
* make sw update work over BLE
# Signal alpha release
Do this "Signal app compatible" release relatively soon after the alpha release of the android app.
* call onNodeDBChanged after we haven't heard a packet from the mesh in a while - because that's how we decide we have less than 2 nodes in the mesh and should stop listening to the local GPS
* add large packet reassembly?
* optionally turn off crypto in signal - preferably though see if there is a nice way to be a peer of signal/sms and now mesh.
* change signal package ID - if distributing modified binary
* investigate the Signal SMS message flow path, see if I could just make Mesh a third peer to signal & sms?
* make signal work when there is no internet up
* make Signal rx path work
* send Signal message type. It seems to be? " public static final int WHISPER_TYPE = 2;
public static final int PREKEY_TYPE = 3;
public static final int SENDERKEY_TYPE = 4;
public static final int SENDERKEY_DISTRIBUTION_TYPE = 5;"
# Done

6
app/.classpath Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

4
app/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/build
/debug
/release
google-services.json

23
app/.project Normal file
View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>app</name>
<comment>Project app created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
</projectDescription>

View file

@ -0,0 +1,2 @@
connection.project.dir=..
eclipse.preferences.version=1

View file

@ -1,70 +0,0 @@
# `:app`
## Overview
The `:app` module is the entry point for the Meshtastic Android application. It orchestrates the various feature modules, manages global state, and provides the main UI shell.
## Key Components
### 1. `MainActivity` & `Main.kt`
The single Activity of the application. It hosts the shared `MeshtasticNavDisplay` navigation shell and manages the root UI structure (Navigation Bar, Rail, etc.).
### 2. `MeshService`
The core background service that manages long-running communication with the mesh radio. While it is declared in the `:app` manifest for system visibility, its implementation resides in the `:core:service` module. It runs as a **Foreground Service** to ensure reliable communication even when the app is in the background.
### 3. Koin Application
`MeshUtilApplication` is the Koin entry point, providing the global dependency injection container.
## Architecture
The module primarily serves as a "glue" layer, connecting:
- `core:*` modules for shared logic.
- `feature:*` modules for specific user-facing screens.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:app[app]:::android-application
:app -.-> :core:ble
:app -.-> :core:common
:app -.-> :core:data
:app -.-> :core:database
:app -.-> :core:datastore
:app -.-> :core:di
:app -.-> :core:domain
:app -.-> :core:model
:app -.-> :core:navigation
:app -.-> :core:network
:app -.-> :core:nfc
:app -.-> :core:prefs
:app -.-> :core:proto
:app -.-> :core:service
:app -.-> :core:resources
:app -.-> :core:ui
:app -.-> :core:barcode
:app -.-> :core:takserver
:app -.-> :feature:intro
:app -.-> :feature:messaging
:app -.-> :feature:connections
:app -.-> :feature:map
:app -.-> :feature:node
:app -.-> :feature:settings
:app -.-> :feature:firmware
:app -.-> :feature:wifi-provision
:app -.-> :feature:widget
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<!--endregion-->

193
app/build.gradle Normal file
View file

@ -0,0 +1,193 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlinx-serialization'
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.github.triplet.play'
apply plugin: 'de.mobilej.unmock'
// apply plugin: "app.brant.amazonappstorepublisher"
// Apply the Crashlytics Gradle plugin
apply plugin: 'com.google.firebase.crashlytics'
// protobuf
apply plugin: 'com.google.protobuf'
apply plugin: 'kotlin-kapt'
unMock {
keep "android.net.Uri"
keep "android.util.Base64"
}
android {
/*
signingConfigs {
release {
storeFile "$meshtasticKeystoreFile"
keyAlias 'upload'
storePassword "$meshtasticKeystorePassword"
keyPassword "$meshtasticKeyPassword"
}
} */
compileSdkVersion 30
// leave undefined to use version plugin wants
// buildToolsVersion "30.0.2" // Note: 30.0.2 doesn't yet work on Github actions CI
defaultConfig {
applicationId "com.geeksville.mesh"
minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works)
targetSdkVersion 29 // 30 can't work until an explicit location permissions dialog is added
versionCode 20230 // format is Mmmss (where M is 1+the numeric major number
versionName "1.2.30"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// per https://developer.android.com/studio/write/vector-asset-studio
vectorDrawables.useSupportLibrary = true
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
pseudoLocalesEnabled true
}
}
defaultConfig {
// We have to list all translated languages here, because some of our libs have bogus languages that google play
// doesn't like and we need to strip them (gr)
resConfigs "cs", "de", "el", "en", "es", "fi", "fr", "ga", "ht", "it", "ja", "ko", "nl", "no", "pt", "ro", "ru", "sk", "sl", "sq", "sv", "tr", "zh"
// Needed to make mapbox work inside the firebase testlab - FIXME, alas, still doesn't work
ndk {
// abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
}
}
buildFeatures {
viewBinding true
}
// Set both the Java and Kotlin compilers to target Java 8.
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
lintOptions {
abortOnError false
}
}
play {
// configure per https://github.com/Triple-T/gradle-play-publisher#authenticating-gradle-play-publisher
track = 'internal'
defaultToAppBundles = true
serviceAccountCredentials = file("../../play-credentials.json")
}
// per protobuf-gradle-plugin docs, this is recommended for android
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.15.3'
}
generateProtoTasks {
all().each { task ->
task.builtins {
java {
// turned off for now so I can use json printing in debug panel
// use the smaller android version of the library
//option "lite"
}
}
}
}
}
dependencies {
def room_version = '2.3.0'
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.fragment:fragment-ktx:1.3.3'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.viewpager2:viewpager2:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1'
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
// optional - Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"
// optional - Test helpers
testImplementation "androidx.room:room-testing:$room_version"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
// kotlin serialization
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:1.0-M1-1.4.0-rc"
// rate this app
implementation "com.vorlonsoft:androidrate:1.2.1"
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
// For now I'm not using javalite, because I want JSON printing
implementation ('com.google.protobuf:protobuf-java:3.15.8')
// For UART access
// implementation 'com.google.android.things:androidthings:1.0'
implementation 'com.github.mik3y:usb-serial-for-android:v3.0.0'
// mapbox
implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:9.6.0'
// mapbox specifies a really old version of okhttp3 which causes lots of API warnings. trying a newer version
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
// location services
implementation 'com.google.android.gms:play-services-location:18.0.0'
// For Google Sign-In (owner name accesss)
implementation 'com.google.android.gms:play-services-auth:19.0.0'
// Add the Firebase SDK for Crashlytics.
implementation 'com.google.firebase:firebase-crashlytics:17.4.1'
// alas implementation bug deep in the bowels when I tried it for my SyncBluetoothDevice class
// implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3"
// add SDKs for any other desired Firebase products
// https://firebase.google.com/docs/android/setup#available-libraries
// barcode support
// per https://github.com/journeyapps/zxing-android-embedded for support of android version 22
implementation('com.journeyapps:zxing-android-embedded:4.1.0') { transitive = false }
implementation 'com.google.zxing:core:3.4.1'
def work_version = '2.5.0'
// Work Request - used to delay boot event handling
// implementation "androidx.work:work-runtime:$work_version"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation project(':geeksville-androidlib')
}

View file

@ -1,338 +0,0 @@
/*
* Copyright (c) 2025-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.ApplicationExtension
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
import org.meshtastic.buildlogic.GitVersionValueSource
import org.meshtastic.buildlogic.configProperties
import java.io.FileInputStream
import java.util.Properties
val gitVersionProvider = providers.of(GitVersionValueSource::class.java) {}
plugins {
alias(libs.plugins.meshtastic.android.application)
alias(libs.plugins.meshtastic.android.application.flavors)
alias(libs.plugins.meshtastic.android.application.compose)
id("meshtastic.koin")
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.secrets)
alias(libs.plugins.aboutlibraries)
id("dev.mokkery")
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) }
}
configure<ApplicationExtension> {
namespace = "org.meshtastic.app"
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
defaultConfig {
applicationId = configProperties.getProperty("APPLICATION_ID")
val vcOffset = configProperties.getProperty("VERSION_CODE_OFFSET")?.toInt() ?: 0
println("Version code offset: $vcOffset")
versionCode =
(
project.findProperty("android.injected.version.code")?.toString()?.toInt()
?: System.getenv("VERSION_CODE")?.toInt()
?: (gitVersionProvider.get().toInt() + vcOffset)
)
versionName =
(
project.findProperty("android.injected.version.name")?.toString()
?: System.getenv("VERSION_NAME")
?: configProperties.getProperty("VERSION_NAME_BASE")
)
buildConfigField("String", "MIN_FW_VERSION", "\"${configProperties.getProperty("MIN_FW_VERSION")}\"")
buildConfigField("String", "ABS_MIN_FW_VERSION", "\"${configProperties.getProperty("ABS_MIN_FW_VERSION")}\"")
// We have to list all translated languages here,
// because some of our libs have bogus languages that google play
// doesn't like and we need to strip them (gr)
@Suppress("UnstableApiUsage")
val ci = project.findProperty("ci")?.toString()?.toBoolean() ?: false
if (ci) {
println("CI build detected - limiting locale filters for faster packaging")
androidResources.localeFilters.addAll(listOf("en"))
} else {
androidResources.localeFilters.addAll(
listOf(
"en",
"ar",
"bg",
"ca",
"cs",
"de",
"el",
"es",
"et",
"fi",
"fr",
"ga",
"gl",
"hr",
"ht",
"hu",
"is",
"it",
"iw",
"ja",
"ko",
"lt",
"nl",
"no",
"pl",
"pt",
"pt-rBR",
"ro",
"ru",
"sk",
"sl",
"sq",
"sr",
"srp",
"sv",
"tr",
"uk",
"zh-rCN",
"zh-rTW",
),
)
}
ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") }
val disableSplits =
project.gradle.startParameter.taskNames.any {
it.contains("bundle", ignoreCase = true) || it.contains("google", ignoreCase = true)
}
// Enable ABI splits to generate smaller APKs per architecture for F-Droid/IzzyOnDroid
splits {
abi {
isEnable = !disableSplits
reset()
include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
isUniversalApk = true
}
}
dependenciesInfo {
// Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid)
includeInApk = false
// Disables dependency metadata when building Android App Bundles (for Google Play)
includeInBundle = false
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// Configure existing product flavors (defined by convention plugin)
// with their dynamic version names.
productFlavors {
configureEach {
versionName = "${defaultConfig.versionName} (${defaultConfig.versionCode}) $name"
if (name == "google") {
manifestPlaceholders["MAPS_API_KEY"] = "dummy"
}
}
}
buildTypes {
release {
if (keystoreProperties["storeFile"] != null) {
signingConfig = signingConfigs.named("release").get()
} else {
signingConfig = signingConfigs.getByName("debug")
}
isDebuggable = false
}
}
bundle { language { enableSplit = false } }
testOptions { unitTests { isIncludeAndroidResources = true } }
}
secrets {
defaultPropertiesFileName = "secrets.defaults.properties"
propertiesFileName = "secrets.properties"
}
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.flavorName?.let { flavor -> variant.applicationId = "com.geeksville.mesh.$flavor.debug" }
}
onVariants(selector().withBuildType("release")) { variant ->
if (variant.flavorName == "google") {
val variantNameCapped = variant.name.replaceFirstChar { it.uppercase() }
val minifyTaskName = "minify${variantNameCapped}WithR8"
val uploadTaskName = "uploadMapping$variantNameCapped"
if (project.tasks.findByName(uploadTaskName) != null && project.tasks.findByName(minifyTaskName) != null) {
tasks.named(minifyTaskName).configure { finalizedBy(uploadTaskName) }
}
}
}
}
project.afterEvaluate {
logger.lifecycle(
"Version code is set to: ${extensions.getByType<ApplicationExtension>().defaultConfig.versionCode}",
)
}
dependencies {
implementation(projects.core.ble)
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.di)
implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(projects.core.network)
implementation(projects.core.nfc)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.service)
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.barcode)
implementation(projects.core.takserver)
implementation(projects.feature.intro)
implementation(projects.feature.messaging)
implementation(projects.feature.connections)
implementation(projects.feature.map)
implementation(projects.feature.node)
implementation(projects.feature.settings)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
implementation(projects.feature.widget)
implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.material)
implementation(libs.compose.multiplatform.animation)
implementation(libs.compose.multiplatform.material3)
implementation(libs.compose.multiplatform.ui.tooling.preview)
implementation(libs.compose.multiplatform.ui)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.appwidget.preview)
implementation(libs.androidx.glance.material3)
implementation(libs.androidx.lifecycle.process)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
implementation(libs.jetbrains.lifecycle.runtime.compose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.coil)
implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlinx.serialization.json)
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.koin.android)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.androidx.workmanager)
implementation(libs.koin.annotations)
implementation(libs.accompanist.permissions)
implementation(libs.kermit)
implementation(libs.kotlinx.datetime)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.glance.preview)
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
googleImplementation(libs.dd.sdk.android.logs)
googleImplementation(libs.dd.sdk.android.rum)
googleImplementation(libs.dd.sdk.android.session.replay)
googleImplementation(libs.dd.sdk.android.session.replay.material)
googleImplementation(libs.dd.sdk.android.timber)
googleImplementation(libs.dd.sdk.android.trace)
googleImplementation(libs.dd.sdk.android.trace.otel)
googleImplementation(platform(libs.firebase.bom))
googleImplementation(libs.firebase.analytics)
googleImplementation(libs.firebase.crashlytics)
fdroidImplementation(libs.osmdroid.android)
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack)
testImplementation(kotlin("test-junit"))
testImplementation(libs.androidx.work.testing)
testImplementation(libs.koin.test)
testRuntimeOnly(libs.junit.vintage.engine)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
testImplementation(libs.compose.multiplatform.ui.test)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.glance.appwidget)
}
aboutLibraries {
// Run offline by default to avoid burning GitHub API calls on every build.
// Release builds pass -PaboutLibraries.release=true to fetch full license text + funding info.
val isReleaseBuild = providers.gradleProperty("aboutLibraries.release").map { it.toBoolean() }.getOrElse(false)
val ghToken = providers.environmentVariable("GITHUB_TOKEN")
offlineMode = !isReleaseBuild
collect {
fetchRemoteLicense = isReleaseBuild && ghToken.isPresent
fetchRemoteFunding = isReleaseBuild && ghToken.isPresent
if (ghToken.isPresent) {
gitHubApiToken = ghToken.get()
}
}
export {
excludeFields = listOf("generated")
outputFile = file("src/main/resources/aboutlibraries.json")
}
library {
duplicationMode = DuplicateMode.MERGE
duplicationRule = DuplicateRule.SIMPLE
}
}
// Ensure aboutlibraries.json is always up-to-date during the build.
// This is required since AboutLibraries v11+ no longer auto-exports.
tasks
.matching { it.name.startsWith("process") && it.name.endsWith("Resources") }
.configureEach { dependsOn("exportLibraryDefinitions") }

View file

@ -1,5 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues/>
</SmellBaseline>

View file

@ -0,0 +1,40 @@
{
"project_info": {
"project_number": "1111",
"firebase_url": "https://xxx.firebaseio.com",
"project_id": "xxx",
"storage_bucket": "xxx.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:xxx:android:1111",
"android_client_info": {
"package_name": "com.geeksville.mesh"
}
},
"oauth_client": [
{
"client_id": "111-xxx.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "xxx-c"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "111-xxx.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View file

@ -1,67 +0,0 @@
{
"project_info": {
"project_number": "1111",
"firebase_url": "https://xxx.firebaseio.com",
"project_id": "xxx",
"storage_bucket": "xxx.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:xxx:android:1111",
"android_client_info": {
"package_name": "com.geeksville.mesh"
}
},
"oauth_client": [
{
"client_id": "111-xxx.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.geeksville.mesh"
}
},
{
"client_id": "111-xxx.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.geeksville.mesh"
}
},
{
"client_id": "111-xxx.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "111-xxx.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:xxx:android:1111",
"android_client_info": {
"package_name": "com.geeksville.mesh.google.debug"
}
},
"api_key": [
{
"current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX"
}
]
}
],
"configuration_version": "1"
}

1
app/google-services.json Symbolic link
View file

@ -0,0 +1 @@
../../google-services.json

View file

@ -1,45 +1,50 @@
# ============================================================================ # Add project specific ProGuard rules here.
# Meshtastic Android ProGuard / R8 rules for release minification # You can control the set of applied configuration files using the
# ============================================================================ # proguardFiles setting in build.gradle.
# Open-source project: obfuscation and optimization are disabled. We rely on
# tree-shaking (unused code removal) for APK size reduction.
# #
# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room, # For more details, see
# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3, # http://developer.android.com/guide/developing/tools/proguard.html
# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in
# config/proguard/shared-rules.pro and are wired in by the
# AndroidApplicationConventionPlugin. This file holds only Android-specific
# rules and R8-only directives.
# ============================================================================
# ---- General ---------------------------------------------------------------- # If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Open-source no need to obfuscate # Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# per https://medium.com/@kenkyee/android-kotlin-coroutine-best-practices-bc033fed62e7
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembernames class kotlinx.** { volatile <fields>; }
# Needed for protobufs
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageV3 { <fields>; }
-keep class com.geeksville.mesh.**{*;}
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { <fields>; }
# Recommended by mapbox to prevent native crashes
-dontwarn com.mapbox.**
-keep class com.mapbox.** { *; }
# for kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class com.geeksville.mesh.**$$serializer { *; }
-keepclassmembers class com.geeksville.mesh.** {
*** Companion;
}
-keepclasseswithmembers class com.geeksville.mesh.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Our app is opensource no need to obsfucate
-dontobfuscate -dontobfuscate
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
# Disable R8 optimization passes. Tree-shaking (unused code removal) still
# runs only method-body rewrites and call-site transformations are suppressed.
#
# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on
# Composer.<clinit>() and ComposerImpl.<clinit>(), plus -assumevalues on
# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives
# let R8 rewrite *call sites* (class-init triggers, flag reads) even when the
# target classes are preserved by -keep rules. The result is that the Compose
# recomposer/frame-clock/animation state machines silently freeze on their
# first frame in release builds. -dontoptimize is the only directive that
# disables processing of -assumenosideeffects/-assumevalues. See #5146.
-dontoptimize
# Dump the full merged R8 configuration (app rules + all library consumer rules)
# for auditing. Inspect this file after a release build to see what libraries inject.
-printconfiguration build/outputs/mapping/r8-merged-config.txt
# ---- Networking (transitive references from Ktor on Android) ----------------
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
# Compose runtime/ui/animation/foundation/material3 keep rules now live in
# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard)
# get the same defence-in-depth coverage against CMP 1.11 optimizer folding.

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This file is kept in source control because it reflects the last stable
release. It is used by the android app for forcing software updates. Do not edit.
Generated by bin/buildall.sh -->
<resources>
<string name="cur_firmware_version">0.2.0</string>
</resources>

View file

@ -0,0 +1,5 @@
<resources>
<string name="mapbox_access_token">pk.xxx.xxx</string>
</resources>

View file

@ -0,0 +1,18 @@
package com.geeksville.mesh
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.geeksville.mesh.model.Channel
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ChannelTest {
@Test
fun channelUrlGood() {
val ch = Channel.emulated
Assert.assertTrue(ch.getChannelUrl().toString().startsWith(Channel.prefix))
Assert.assertEquals(Channel(ch.getChannelUrl()), ch)
}
}

View file

@ -0,0 +1,22 @@
package com.geeksville.mesh
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.geeksville.mesh", appContext.packageName)
}
}

View file

@ -1,216 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.cluster;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Point;
import android.view.MotionEvent;
import org.meshtastic.app.map.model.MarkerWithLabel;
import org.osmdroid.util.BoundingBox;
import org.osmdroid.views.MapView;
import org.osmdroid.views.overlay.Overlay;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.ListIterator;
/**
* An overlay allowing to perform markers clustering.
* Usage: put your markers inside with add(Marker), and add the MarkerClusterer to the map overlays.
* Depending on the zoom level, markers will be displayed separately, or grouped as a single Marker. <br/>
*
* This abstract class provides the framework. Sub-classes have to implement the clustering algorithm,
* and the rendering of a cluster.
*
* @author M.Kergall
*
*/
public abstract class MarkerClusterer extends Overlay {
/** impossible value for zoom level, to force clustering */
protected static final int FORCE_CLUSTERING = -1;
protected ArrayList<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
protected Point mPoint = new Point();
protected ArrayList<StaticCluster> mClusters = new ArrayList<StaticCluster>();
protected int mLastZoomLevel;
protected Bitmap mClusterIcon;
protected String mName, mDescription;
// abstract methods:
/** clustering algorithm */
public abstract ArrayList<StaticCluster> clusterer(MapView mapView);
/** Build the marker for a cluster. */
public abstract MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView);
/** build clusters markers to be used at next draw */
public abstract void renderer(ArrayList<StaticCluster> clusters, Canvas canvas, MapView mapView);
public MarkerClusterer() {
super();
mLastZoomLevel = FORCE_CLUSTERING;
}
public void setName(String name){
mName = name;
}
public String getName(){
return mName;
}
public void setDescription(String description){
mDescription = description;
}
public String getDescription(){
return mDescription;
}
/** Set the cluster icon to be drawn when a cluster contains more than 1 marker.
* If not set, default will be the default osmdroid marker icon (which is really inappropriate as a cluster icon). */
public void setIcon(Bitmap icon){
mClusterIcon = icon;
}
/** Add the Marker.
* Important: Markers added in a MarkerClusterer should not be added in the map overlays. */
public void add(MarkerWithLabel marker){
mItems.add(marker);
}
/** Force a rebuild of clusters at next draw, even without a zooming action.
* Should be done when you changed the content of a MarkerClusterer. */
public void invalidate(){
mLastZoomLevel = FORCE_CLUSTERING;
}
/** @return the Marker at id (starting at 0) */
public MarkerWithLabel getItem(int id){
return mItems.get(id);
}
/** @return the list of Markers. */
public ArrayList<MarkerWithLabel> getItems(){
return mItems;
}
protected void hideInfoWindows(){
for (MarkerWithLabel m : mItems){
if (m.isInfoWindowShown())
m.closeInfoWindow();
}
}
@Override public void draw(Canvas canvas, MapView mapView, boolean shadow) {
if (shadow)
return;
//if zoom has changed and mapView is now stable, rebuild clusters:
int zoomLevel = mapView.getZoomLevel();
if (zoomLevel != mLastZoomLevel && !mapView.isAnimating()){
hideInfoWindows();
mClusters = clusterer(mapView);
renderer(mClusters, canvas, mapView);
mLastZoomLevel = zoomLevel;
}
for (StaticCluster cluster:mClusters){
MarkerWithLabel marker = cluster.getMarker();
marker.draw(canvas, mapView, false);
}
}
public Iterable<StaticCluster> reversedClusters() {
return new Iterable<StaticCluster>() {
@Override
public Iterator<StaticCluster> iterator() {
final ListIterator<StaticCluster> i = mClusters.listIterator(mClusters.size());
return new Iterator<StaticCluster>() {
@Override
public boolean hasNext() {
return i.hasPrevious();
}
@Override
public StaticCluster next() {
return i.previous();
}
@Override
public void remove() {
i.remove();
}
};
}
};
}
@Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){
for (final StaticCluster cluster : reversedClusters()) {
if (cluster.getMarker().onSingleTapConfirmed(event, mapView))
return true;
}
return false;
}
@Override public boolean onLongPress(final MotionEvent event, final MapView mapView) {
for (final StaticCluster cluster : reversedClusters()) {
if (cluster.getMarker().onLongPress(event, mapView))
return true;
}
return false;
}
@Override public boolean onTouchEvent(final MotionEvent event, final MapView mapView) {
for (StaticCluster cluster : reversedClusters()) {
if (cluster.getMarker().onTouchEvent(event, mapView))
return true;
}
return false;
}
@Override public boolean onDoubleTap(final MotionEvent event, final MapView mapView) {
for (final StaticCluster cluster : reversedClusters()) {
if (cluster.getMarker().onDoubleTap(event, mapView))
return true;
}
return false;
}
@Override public BoundingBox getBounds(){
if (mItems.size() == 0)
return null;
double minLat = Double.MAX_VALUE;
double minLon = Double.MAX_VALUE;
double maxLat = -Double.MAX_VALUE;
double maxLon = -Double.MAX_VALUE;
for (final MarkerWithLabel item : mItems) {
final double latitude = item.getPosition().getLatitude();
final double longitude = item.getPosition().getLongitude();
minLat = Math.min(minLat, latitude);
minLon = Math.min(minLon, longitude);
maxLat = Math.max(maxLat, latitude);
maxLon = Math.max(maxLon, longitude);
}
return new BoundingBox(maxLat, maxLon, minLat, minLon);
}
}

View file

@ -1,213 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.cluster;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.MotionEvent;
import org.meshtastic.app.map.model.MarkerWithLabel;
import org.osmdroid.bonuspack.R;
import org.osmdroid.util.BoundingBox;
import org.osmdroid.util.GeoPoint;
import org.osmdroid.views.MapView;
import java.util.ArrayList;
import java.util.Iterator;
/**
* Radius-based Clustering algorithm:
* create a cluster using the first point from the cloned list.
* All points that are found within the neighborhood are added to this cluster.
* Then all the neighbors and the main point are removed from the list of points.
* It continues until the list is empty.
*
* Largely inspired from GridMarkerClusterer by M.Kergall
*
* @author sidorovroman92@gmail.com
*/
public class RadiusMarkerClusterer extends MarkerClusterer {
protected int mMaxClusteringZoomLevel = 7;
protected int mRadiusInPixels = 100;
protected double mRadiusInMeters;
protected Paint mTextPaint;
private ArrayList<MarkerWithLabel> mClonedMarkers;
protected boolean mAnimated;
int mDensityDpi;
/** cluster icon anchor */
public float mAnchorU = MarkerWithLabel.ANCHOR_CENTER, mAnchorV = MarkerWithLabel.ANCHOR_CENTER;
/** anchor point to draw the number of markers inside the cluster icon */
public float mTextAnchorU = MarkerWithLabel.ANCHOR_CENTER, mTextAnchorV = MarkerWithLabel.ANCHOR_CENTER;
public RadiusMarkerClusterer(Context ctx) {
super();
mTextPaint = new Paint();
mTextPaint.setColor(Color.WHITE);
mTextPaint.setTextSize(15 * ctx.getResources().getDisplayMetrics().density);
mTextPaint.setFakeBoldText(true);
mTextPaint.setTextAlign(Paint.Align.CENTER);
mTextPaint.setAntiAlias(true);
Drawable clusterIconD = ctx.getResources().getDrawable(R.drawable.marker_cluster);
Bitmap clusterIcon = ((BitmapDrawable) clusterIconD).getBitmap();
setIcon(clusterIcon);
mAnimated = true;
mDensityDpi = ctx.getResources().getDisplayMetrics().densityDpi;
}
/** If you want to change the default text paint (color, size, font) */
public Paint getTextPaint(){
return mTextPaint;
}
/** Set the radius of clustering in pixels. Default is 100px. */
public void setRadius(int radius){
mRadiusInPixels = radius;
}
/** Set max zoom level with clustering. When zoom is higher or equal to this level, clustering is disabled.
* You can put a high value to disable this feature. */
public void setMaxClusteringZoomLevel(int zoom){
mMaxClusteringZoomLevel = zoom;
}
/** Radius-Based clustering algorithm */
@Override public ArrayList<StaticCluster> clusterer(MapView mapView) {
ArrayList<StaticCluster> clusters = new ArrayList<StaticCluster>();
convertRadiusToMeters(mapView);
mClonedMarkers = new ArrayList<MarkerWithLabel>(mItems); //shallow copy
while (!mClonedMarkers.isEmpty()) {
MarkerWithLabel m = mClonedMarkers.get(0);
StaticCluster cluster = createCluster(m, mapView);
clusters.add(cluster);
}
return clusters;
}
private StaticCluster createCluster(MarkerWithLabel m, MapView mapView) {
GeoPoint clusterPosition = m.getPosition();
StaticCluster cluster = new StaticCluster(clusterPosition);
cluster.add(m);
mClonedMarkers.remove(m);
if (mapView.getZoomLevel() > mMaxClusteringZoomLevel) {
//above max level => block clustering:
return cluster;
}
Iterator<MarkerWithLabel> it = mClonedMarkers.iterator();
while (it.hasNext()) {
MarkerWithLabel neighbor = it.next();
double distance = clusterPosition.distanceToAsDouble(neighbor.getPosition());
if (distance <= mRadiusInMeters) {
cluster.add(neighbor);
it.remove();
}
}
return cluster;
}
@Override public MarkerWithLabel buildClusterMarker(StaticCluster cluster, MapView mapView) {
MarkerWithLabel m = new MarkerWithLabel(mapView, "", null);
m.setPosition(cluster.getPosition());
m.setInfoWindow(null);
m.setAnchor(mAnchorU, mAnchorV);
Bitmap finalIcon = Bitmap.createBitmap(mClusterIcon.getScaledWidth(mDensityDpi),
mClusterIcon.getScaledHeight(mDensityDpi), mClusterIcon.getConfig());
Canvas iconCanvas = new Canvas(finalIcon);
iconCanvas.drawBitmap(mClusterIcon, 0, 0, null);
String text = "" + cluster.getSize();
int textHeight = (int) (mTextPaint.descent() + mTextPaint.ascent());
iconCanvas.drawText(text,
mTextAnchorU * finalIcon.getWidth(),
mTextAnchorV * finalIcon.getHeight() - textHeight / 2,
mTextPaint);
m.setIcon(new BitmapDrawable(mapView.getContext().getResources(), finalIcon));
return m;
}
@Override public void renderer(ArrayList<StaticCluster> clusters, Canvas canvas, MapView mapView) {
for (StaticCluster cluster : clusters) {
if (cluster.getSize() == 1) {
//cluster has only 1 marker => use it as it is:
cluster.setMarker(cluster.getItem(0));
} else {
//only draw 1 Marker at Cluster center, displaying number of Markers contained
MarkerWithLabel m = buildClusterMarker(cluster, mapView);
cluster.setMarker(m);
}
}
}
private void convertRadiusToMeters(MapView mapView) {
Rect mScreenRect = mapView.getIntrinsicScreenRect(null);
int screenWidth = mScreenRect.right - mScreenRect.left;
int screenHeight = mScreenRect.bottom - mScreenRect.top;
BoundingBox bb = mapView.getBoundingBox();
double diagonalInMeters = bb.getDiagonalLengthInMeters();
double diagonalInPixels = Math.sqrt(screenWidth * screenWidth + screenHeight * screenHeight);
double metersInPixel = diagonalInMeters / diagonalInPixels;
mRadiusInMeters = mRadiusInPixels * metersInPixel;
}
public void setAnimation(boolean animate){
mAnimated = animate;
}
public void zoomOnCluster(MapView mapView, StaticCluster cluster){
BoundingBox bb = cluster.getBoundingBox();
if (bb.getLatNorth()!=bb.getLatSouth() || bb.getLonEast()!=bb.getLonWest()) {
bb = bb.increaseByScale(2.3f);
mapView.zoomToBoundingBox(bb, true);
} else //all points exactly at the same place:
mapView.setExpectedCenter(bb.getCenterWithDateLine());
}
@Override public boolean onSingleTapConfirmed(final MotionEvent event, final MapView mapView){
for (final StaticCluster cluster : reversedClusters()) {
if (cluster.getMarker().onSingleTapConfirmed(event, mapView)) {
if (mAnimated && cluster.getSize() > 1)
zoomOnCluster(mapView, cluster);
return true;
}
}
return false;
}
}

View file

@ -1,85 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.cluster;
import org.meshtastic.app.map.model.MarkerWithLabel;
import org.osmdroid.util.BoundingBox;
import org.osmdroid.util.GeoPoint;
import java.util.ArrayList;
/**
* Cluster of Markers.
* @author M.Kergall
*/
public class StaticCluster {
protected final ArrayList<MarkerWithLabel> mItems = new ArrayList<MarkerWithLabel>();
protected GeoPoint mCenter;
protected MarkerWithLabel mMarker;
public StaticCluster(GeoPoint center) {
mCenter = center;
}
public void setPosition(GeoPoint center){
mCenter = center;
}
public GeoPoint getPosition() {
return mCenter;
}
public int getSize() {
return mItems.size();
}
public MarkerWithLabel getItem(int index) {
return mItems.get(index);
}
public boolean add(MarkerWithLabel t) {
return mItems.add(t);
}
/** set the Marker to be displayed for this cluster */
public void setMarker(MarkerWithLabel marker){
mMarker = marker;
}
/** @return the Marker to be displayed for this cluster */
public MarkerWithLabel getMarker(){
return mMarker;
}
public BoundingBox getBoundingBox(){
if (getSize()==0)
return null;
GeoPoint p = getItem(0).getPosition();
BoundingBox bb = new BoundingBox(p.getLatitude(), p.getLongitude(), p.getLatitude(), p.getLongitude());
for (int i=1; i<getSize(); i++) {
p = getItem(i).getPosition();
double minLat = Math.min(bb.getLatSouth(), p.getLatitude());
double minLon = Math.min(bb.getLonWest(), p.getLongitude());
double maxLat = Math.max(bb.getLatNorth(), p.getLatitude());
double maxLon = Math.max(bb.getLonEast(), p.getLongitude());
bb.set(maxLat, maxLon, minLat, minLon);
}
return bb;
}
}

Some files were not shown because too many files have changed in this diff Show more