mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-08 03:21:23 -04:00
Merge remote-tracking branch 'origin/main' into screen-mirror-poc
This commit is contained in:
commit
250fe78ca9
2202 files changed
+75135
-46088
No files matched your search
+20
-14
@@ -2,21 +2,26 @@
|
||||
#
|
||||
# PostToolUse hook (Edit|Write|MultiEdit) for Meshtastic-Android.
|
||||
#
|
||||
# Front-runs three of this repo's own CI/governance gates locally, so the
|
||||
# Front-runs four of this repo's own CI/governance gates locally, so the
|
||||
# failure surfaces at edit time instead of in CI. Dispatches by edited path:
|
||||
#
|
||||
# - base strings.xml -> run scripts/sort-strings.py (keeps the file sorted
|
||||
# and regenerates .skills/compose-ui/strings-index.txt;
|
||||
# AGENTS.md mandates this but no CI job enforces it)
|
||||
# - fastlane/metadata/** -> run scripts/check-metadata-length.py and BLOCK on
|
||||
# overlength store listings (the pull-request.yml
|
||||
# - fastlane/metadata/** -> run scripts/check-store-metadata.py and BLOCK on
|
||||
# store-rule violations (the pull-request.yml
|
||||
# check-metadata job is blocking; F-Droid #4262)
|
||||
# - settings.gradle.kts -> remind about the pull-request.yml paths-filter drift
|
||||
# guard for NEW top-level modules (#5735)
|
||||
# - settings.gradle.kts -> remind about the pull-request.yml paths-filter and
|
||||
# ALL_MODULES_FULL drift guards for NEW top-level modules
|
||||
# - commonMain/commonTest -> BLOCK on java.*/android.* imports in .kt files (the
|
||||
# KMP boundary, otherwise first caught by the iOS compile
|
||||
# in kmpSmokeCompile for main sources or allTests for tests)
|
||||
#
|
||||
# Kotlin edits outside tests and previews also get warn-only Compose-pitfall notes.
|
||||
#
|
||||
# FAILS OPEN: any tooling/parse error allows the edit to stand (exit 0). Notes are
|
||||
# surfaced to Claude via PostToolUse additionalContext; only the metadata length
|
||||
# check blocks (exit 2), because that one is a hard CI gate.
|
||||
# surfaced to Claude via PostToolUse additionalContext; only the store metadata and
|
||||
# KMP-boundary checks block (exit 2), because each front-runs a failing CI job.
|
||||
|
||||
input=$(cat)
|
||||
|
||||
@@ -41,8 +46,7 @@ emit_context() {
|
||||
|
||||
case "$file_path" in
|
||||
*core/resources/src/commonMain/composeResources/values/strings.xml)
|
||||
out=$( (cd "$repo_root" && python3 scripts/sort-strings.py) 2>&1 )
|
||||
if [ $? -eq 0 ]; then
|
||||
if out=$( (cd "$repo_root" && python3 scripts/sort-strings.py) 2>&1 ); then
|
||||
emit_context "Auto-ran scripts/sort-strings.py: base strings.xml re-sorted and .skills/compose-ui/strings-index.txt regenerated. Line positions changed — re-read the file before any further edits to it."
|
||||
else
|
||||
emit_context "Tried to auto-run scripts/sort-strings.py after your strings.xml edit but it failed (likely malformed XML in what was just written — please check):
|
||||
@@ -51,11 +55,13 @@ $out"
|
||||
;;
|
||||
|
||||
*fastlane/metadata/android/*)
|
||||
out=$( (cd "$repo_root" && python3 scripts/check-metadata-length.py) 2>&1 )
|
||||
if [ $? -ne 0 ]; then
|
||||
out=$( (cd "$repo_root" && python3 scripts/check-store-metadata.py) 2>&1 )
|
||||
rc=$?
|
||||
# 1 is a store-rule violation; any other failure is tooling and fails open.
|
||||
if [ "$rc" -eq 1 ]; then
|
||||
{
|
||||
printf '%s\n' "Store-listing metadata exceeds a length limit (scripts/check-metadata-length.py)."
|
||||
printf '%s\n' "Fix this before it lands — the pull-request.yml check-metadata job is blocking (F-Droid #4262; limits count Unicode code points, not bytes). Details:"
|
||||
printf '%s\n' "Store-listing metadata breaks a store rule (scripts/check-store-metadata.py)."
|
||||
printf '%s\n' "Fix this before it lands: the pull-request.yml check-metadata job is blocking (F-Droid #4262; limits count Unicode code points, not bytes). Details:"
|
||||
printf '%s\n' "$out"
|
||||
} >&2
|
||||
exit 2
|
||||
@@ -64,7 +70,7 @@ $out"
|
||||
;;
|
||||
|
||||
*settings.gradle.kts)
|
||||
emit_context "You edited settings.gradle.kts. If you added a NEW TOP-LEVEL module directory, add its '<root>/**' line to the 'android:' paths-filter in .github/workflows/pull-request.yml (case-sensitive) or the verify-check-changes-filter drift guard will fail the PR (bit us on #5735). New sub-modules under an already-listed root (core/**, feature/**, etc.) are already covered — no change needed."
|
||||
emit_context "You edited settings.gradle.kts. If you added a NEW TOP-LEVEL module directory, add its '<root>/**' line to the 'android:' paths-filter in .github/workflows/pull-request.yml (case-sensitive) or scripts/check-changes-filter.py will fail the PR, and add the module to ALL_MODULES_FULL in RootConventionPlugin.kt or scripts/check-module-list.py will. New sub-modules under an already-listed root (core/**, feature/**, etc.) need no filter change."
|
||||
;;
|
||||
|
||||
*/src/commonMain/*.kt|*/src/commonTest/*.kt)
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/ci-cost-control
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/code-review
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/compose-ui
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/design-standards
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/implement-feature
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/kmp-architecture
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/navigation-and-di
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/new-branch
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/project-overview
|
||||
@@ -123,11 +123,14 @@ python3 .claude/skills/run-meshtastic-android/driver_emulator.py -s emulator-555
|
||||
connect=t10.0.2.2:4404 wait_text=RPLY ss=/tmp/emu.png
|
||||
```
|
||||
|
||||
`connect` force-stops the app, relaunches `org.meshtastic.app.MainActivity` with the
|
||||
debug-only `skip_onboarding` extra and the `/connections?address=` deeplink
|
||||
(`t` = TCP, `x` = BLE, `s` = serial, `n` = disconnect — full path list in
|
||||
`docs/en/developer/navigation-and-deep-links.md`), then waits for the trust dialog
|
||||
newer builds pop and taps its **Connect** button. Success looks like the Connection
|
||||
`connect` force-stops the app, relaunches it through `org.meshtastic.app.AutomationLauncher`
|
||||
with the `skip_onboarding` and `skip_connect_confirm` extras and the
|
||||
`/connections?address=` deeplink (`t` = TCP, `x` = BLE, `s` = serial, `n` = disconnect —
|
||||
full path list in `docs/en/developer/navigation-and-deep-links.md`). That alias exists
|
||||
only in debug builds and only the shell may start it, and the extras do nothing on any
|
||||
other launch. With `skip_connect_confirm` the address is applied with no trust dialog; a
|
||||
build that predates the alias still pops it, and the driver taps its **Connect** button. Desktop
|
||||
takes the same switch as a `--skip-connect-confirm` argument in a non-release build. Success looks like the Connection
|
||||
screen showing `RPLY Replay Observer` with a **Disconnect** button, and
|
||||
`replay_status` reporting `connected:true`.
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Drive the Meshtastic Android app on an emulator/device over adb.
|
||||
|
||||
Scripted bring-up (never hand-walk onboarding): launches MainActivity with the
|
||||
debug-only skip_onboarding extra and a /connections deeplink that auto-connects
|
||||
to a TCP radio — pair it with a replay-sim radio (an AVD reaches the host at
|
||||
Scripted bring-up (never hand-walk onboarding): launches the debug build's
|
||||
shell-only AutomationLauncher alias with the skip_onboarding extra and a
|
||||
/connections deeplink that auto-connects to a TCP radio — pair it with a
|
||||
replay-sim radio (an AVD reaches the host at
|
||||
10.0.2.2). Handles the trust dialog newer builds pop on first connect.
|
||||
|
||||
Usage:
|
||||
@@ -38,18 +39,30 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
SERIAL = None
|
||||
PKG = "com.geeksville.mesh.fdroid.debug"
|
||||
ACTIVITY = "org.meshtastic.app.MainActivity"
|
||||
ACTIVITY = "org.meshtastic.app.AutomationLauncher"
|
||||
# Builds without the alias; they ignore the launch switches.
|
||||
FALLBACK_ACTIVITY = "org.meshtastic.app.MainActivity"
|
||||
|
||||
|
||||
def adb(*args):
|
||||
cmd = ["adb"] + (["-s", SERIAL] if SERIAL else []) + list(args)
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=120)
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or b"").decode(errors="replace").strip()
|
||||
# am start reports a missing component on stdout
|
||||
err = ((r.stderr or b"") + (r.stdout or b"")).decode(errors="replace").strip()
|
||||
raise RuntimeError(f"adb {' '.join(args)} failed ({r.returncode}): {err[:300]}")
|
||||
return (r.stdout or b"").decode(errors="replace")
|
||||
|
||||
|
||||
def start_app(*extras):
|
||||
try:
|
||||
return adb("shell", "am", "start", "-n", f"{PKG}/{ACTIVITY}", *extras)
|
||||
except RuntimeError as e:
|
||||
if "does not exist" not in str(e):
|
||||
raise
|
||||
return adb("shell", "am", "start", "-n", f"{PKG}/{FALLBACK_ACTIVITY}", *extras)
|
||||
|
||||
|
||||
def ui_dump():
|
||||
adb("shell", "uiautomator", "dump", "/sdcard/ui.xml")
|
||||
return adb("shell", "cat", "/sdcard/ui.xml")
|
||||
@@ -118,19 +131,26 @@ def wait_text(text, timeout=60):
|
||||
def connect(addr):
|
||||
adb("shell", "am", "force-stop", PKG)
|
||||
time.sleep(1)
|
||||
adb(
|
||||
"shell", "am", "start", "-n", f"{PKG}/{ACTIVITY}",
|
||||
start_app(
|
||||
"--ez", "skip_onboarding", "true",
|
||||
"--ez", "skip_connect_confirm", "true",
|
||||
"-a", "android.intent.action.VIEW",
|
||||
"-d", f"https://meshtastic.org/connections?address={addr}",
|
||||
)
|
||||
# Builds >2.8.1 pop a trust dialog on first connect to a new device. Match its
|
||||
# title, not bare "Connect" — that substring also matches "Stop Connecting".
|
||||
r = wait_text("Connect to this device", timeout=30)
|
||||
if r.startswith("found"):
|
||||
print(tap_text("Connect"))
|
||||
# Debug builds with the AutomationLauncher alias apply the address with no dialog;
|
||||
# older ones pop the trust dialog, sometimes late on a slow emulator.
|
||||
# Watch for either for 30 s. Match the dialog's title, not bare "Connect", which
|
||||
# also matches "Stop Connecting".
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
if any(True for _ in find("Disconnect")):
|
||||
break
|
||||
if any(True for _ in find("Connect to this device")):
|
||||
print(tap_text("Connect"))
|
||||
break
|
||||
time.sleep(3)
|
||||
else:
|
||||
print("no trust dialog seen — verifying the connection directly")
|
||||
print("neither the trust dialog nor a connection appeared in 30 s")
|
||||
# A missing dialog does not prove success (the launch or deeplink may have failed):
|
||||
# require the Connection screen's Disconnect button before claiming victory.
|
||||
v = wait_text("Disconnect", timeout=60)
|
||||
@@ -162,7 +182,7 @@ def main():
|
||||
if res.startswith("FAILED"):
|
||||
return 1
|
||||
elif name == "launch":
|
||||
adb("shell", "am", "start", "-n", f"{PKG}/{ACTIVITY}", "--ez", "skip_onboarding", "true")
|
||||
start_app("--ez", "skip_onboarding", "true")
|
||||
print("launched")
|
||||
elif name == "stop":
|
||||
adb("shell", "am", "force-stop", PKG)
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/speckit
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.skills/testing-ci
|
||||
+16
-1
@@ -8,6 +8,16 @@ reviews:
|
||||
profile: chill
|
||||
high_level_summary: true
|
||||
poem: false
|
||||
# Keep the walkthrough to the summary and the changed files. review_status stays on:
|
||||
# tooling reads the paused, draft and rate-limited notices from it.
|
||||
sequence_diagrams: false
|
||||
related_prs: false
|
||||
related_issues: false
|
||||
suggested_labels: false
|
||||
suggested_reviewers: false
|
||||
estimate_code_review_effort: false
|
||||
in_progress_fortune: false
|
||||
enable_prompt_for_ai_agents: false
|
||||
# Don't burn reviews on WIP. This repo opens lots of draft PRs; review on "ready".
|
||||
# Skip Renovate dependency updates — CI gates dependencies; we review for substance, not every bump.
|
||||
auto_review:
|
||||
@@ -146,7 +156,7 @@ reviews:
|
||||
instructions: >
|
||||
New string resources must be alphabetically sorted (scripts/sort-strings.py). Flag out-of-order additions.
|
||||
- path: baselineprofile/
|
||||
instructions: Keep baseline profile generation tied to the `google` flavor and connected devices/emulators, and commit the generated profile output to `androidApp/src/googleRelease/generated/baselineProfiles/baseline-prof.txt`.
|
||||
instructions: Keep baseline profile generation tied to the `google` flavor and connected devices/emulators, and commit the generated profile output to `androidApp/src/main/generated/baselineProfiles/baseline-prof.txt`, which both flavors ship.
|
||||
- path: docs/
|
||||
instructions: Treat non-English locale folders as Crowdin-managed output; edit the English sources under `docs/en/` and register new pages through `feature/docs/` instead of hand-editing translated locale directories.
|
||||
- path: screenshot-tests/
|
||||
@@ -324,6 +334,11 @@ reviews:
|
||||
good opportunity to fix during this refactor" so the author can decide on scope. Do not flag
|
||||
a move that the diff shows to be genuinely mechanical.
|
||||
|
||||
chat:
|
||||
# Answer only when tagged, so a plain reply on a thread does not draw a review of its own.
|
||||
# Tag @coderabbitai on a decline whose reason should become a learning.
|
||||
auto_reply: false
|
||||
|
||||
knowledge_base:
|
||||
# Learnings are how a confirmed finding stops recurring on the next PR. Pin the
|
||||
# scope to this repo: the default `auto` already resolves to `local` for public
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Bug Report
|
||||
description: File a bug report.
|
||||
title: "[Bug]: "
|
||||
labels: [bug]
|
||||
projects: [meshtastic/Meshtastic-Android]
|
||||
projects: [meshtastic/26]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Feature Request
|
||||
description: File a request for new feature or functionality.
|
||||
title: "[Feature Request]: "
|
||||
labels: [enhancement]
|
||||
projects: [meshtastic/Meshtastic-Android]
|
||||
projects: [meshtastic/30]
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Internal testing - Bug Report
|
||||
description: File a bug report.
|
||||
title: "[Bug]: "
|
||||
labels: [bug, ch_testing]
|
||||
projects: [meshtastic/Meshtastic-Android]
|
||||
projects: [meshtastic/26]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Bot PR
|
||||
description: Open or update a PR from the working tree's changes and hand it to the merge queue.
|
||||
# The token must be a PAT: GITHUB_TOKEN may not enable auto-merge on protected main, and
|
||||
# PRs it opens start no workflows, so the required checks would never report.
|
||||
inputs:
|
||||
token:
|
||||
description: 'PAT that pushes the branch, opens the PR and requests the merge'
|
||||
required: true
|
||||
branch:
|
||||
description: 'PR head branch'
|
||||
required: true
|
||||
base:
|
||||
description: 'PR base branch; must be the branch checked out in the workspace'
|
||||
default: 'main'
|
||||
title:
|
||||
description: 'PR title'
|
||||
required: true
|
||||
commit-message:
|
||||
description: 'Commit message; defaults to the title'
|
||||
default: ''
|
||||
body:
|
||||
description: 'PR body'
|
||||
default: ''
|
||||
add-paths:
|
||||
description: 'Newline-separated pathspecs to commit; all changes when empty'
|
||||
default: ''
|
||||
labels:
|
||||
description: 'Newline-separated labels'
|
||||
default: ''
|
||||
outputs:
|
||||
number:
|
||||
description: 'PR number'
|
||||
value: ${{ steps.pr.outputs.pull-request-number }}
|
||||
url:
|
||||
description: 'PR URL'
|
||||
value: ${{ steps.pr.outputs.pull-request-url }}
|
||||
operation:
|
||||
description: 'created, updated, closed or none'
|
||||
value: ${{ steps.pr.outputs.pull-request-operation }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Open or update the PR
|
||||
id: pr
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ inputs.token }}
|
||||
branch: ${{ inputs.branch }}
|
||||
base: ${{ inputs.base }}
|
||||
title: ${{ inputs.title }}
|
||||
commit-message: ${{ inputs.commit-message || inputs.title }}
|
||||
body: ${{ inputs.body }}
|
||||
add-paths: ${{ inputs.add-paths }}
|
||||
labels: ${{ inputs.labels }}
|
||||
delete-branch: true
|
||||
|
||||
# No merge method is passed; the queue sets it. Re-requested on every update, since a
|
||||
# push can clear the request. GitHub refuses it on a PR that is already mergeable, so
|
||||
# that case merges directly.
|
||||
- name: Enable auto-merge
|
||||
if: ${{ steps.pr.outputs.pull-request-operation == 'created' || steps.pr.outputs.pull-request-operation == 'updated' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ inputs.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pull-request-number }}
|
||||
run: |
|
||||
PR_ID=$(gh pr view "$PR_NUMBER" --json id --jq .id)
|
||||
QUERY=$(cat <<'GQL'
|
||||
mutation($id: ID!) {
|
||||
enablePullRequestAutoMerge(input: { pullRequestId: $id }) {
|
||||
pullRequest { number autoMergeRequest { enabledAt } }
|
||||
}
|
||||
}
|
||||
GQL
|
||||
)
|
||||
gh api graphql -f query="$QUERY" -F id="$PR_ID" \
|
||||
|| gh pr merge "$PR_NUMBER"
|
||||
@@ -2,13 +2,22 @@ name: Gradle Setup
|
||||
description: Setup Java and Gradle for KMP builds
|
||||
inputs:
|
||||
cache_read_only:
|
||||
description: 'Whether Gradle cache is read-only'
|
||||
description: 'Whether the Gradle, Kotlin/Native and Robolectric caches are read-only'
|
||||
default: 'true'
|
||||
cache_konan:
|
||||
description: 'Cache ~/.konan. Only jobs that run Kotlin/Native tasks read it.'
|
||||
default: 'false'
|
||||
cache_robolectric:
|
||||
description: 'Cache the Robolectric android-all jars in ~/.m2'
|
||||
default: 'true'
|
||||
jdk_distribution:
|
||||
description: 'JDK distribution (temurin or jetbrains)'
|
||||
default: 'temurin'
|
||||
install_jetbrains_jdk:
|
||||
description: 'Also install JetBrains JDK 25 for Compose Desktop toolchain resolution'
|
||||
description: |
|
||||
Also install JetBrains JDK 25 with setup-java. Gradle detects it through ~/.m2/toolchains.xml
|
||||
instead of provisioning one through foojay, so the Linux release installers bundle this JBR
|
||||
and flatpak-sources records no foojay download as a source.
|
||||
default: 'false'
|
||||
gradle_encryption_key:
|
||||
description: 'Encryption key for Gradle remote cache'
|
||||
@@ -24,20 +33,8 @@ inputs:
|
||||
dependency_graph:
|
||||
description: |
|
||||
Dependency graph mode: disabled | generate | generate-and-submit | generate-and-upload |
|
||||
download-and-submit. Submit needs `contents: write`. Never combine with
|
||||
cache_configuration_cache — a CC-hit build generates NO graph.
|
||||
download-and-submit. Submit needs `contents: write`.
|
||||
default: 'disabled'
|
||||
cache_configuration_cache:
|
||||
description: |
|
||||
Persist .gradle/configuration-cache. Opt-in: only pays off when config inputs are
|
||||
commit-stable (the VERSION_CODE-pinned jobs). Real-versionCode jobs would restore,
|
||||
miss, and never re-save.
|
||||
default: 'false'
|
||||
cache_key_suffix:
|
||||
description: |
|
||||
Extra CC-key discriminator for matrix legs running different task graphs (test-shards).
|
||||
Runner-only matrices don't need it — os/arch are already in the key.
|
||||
default: ''
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -45,9 +42,6 @@ runs:
|
||||
shell: bash
|
||||
run: mkdir -p ~/.gradle && cp .github/ci-gradle.properties ~/.gradle/gradle.properties
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
|
||||
with:
|
||||
@@ -55,16 +49,8 @@ runs:
|
||||
distribution: ${{ inputs.jdk_distribution }}
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Restore cached JetBrains JDK 25
|
||||
- name: Set up JetBrains JDK 25
|
||||
if: inputs.install_jetbrains_jdk == 'true'
|
||||
id: cache-jbr
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ${{ runner.tool_cache }}/Java_JetBrains_jdk
|
||||
key: jbr-25-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Set up JetBrains JDK 25 (for Compose Desktop)
|
||||
if: inputs.install_jetbrains_jdk == 'true' && steps.cache-jbr.outputs.cache-hit != 'true'
|
||||
id: setup-jbr
|
||||
continue-on-error: true
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
|
||||
@@ -74,32 +60,66 @@ runs:
|
||||
check-latest: false
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: JBR setup skipped or failed — Gradle will auto-provision via Foojay
|
||||
if: inputs.install_jetbrains_jdk == 'true' && steps.cache-jbr.outputs.cache-hit != 'true' && steps.setup-jbr.outcome == 'failure'
|
||||
- name: JBR setup failed, Gradle will provision it through foojay
|
||||
if: inputs.install_jetbrains_jdk == 'true' && steps.setup-jbr.outcome == 'failure'
|
||||
shell: bash
|
||||
run: echo "::warning::JBR setup-java failed (likely GitHub API rate limit). Gradle will auto-provision JBR via Foojay toolchain resolver."
|
||||
|
||||
# Kotlin/Native lives in ~/.konan, OUTSIDE the Gradle home that
|
||||
# setup-gradle caches — without this every job re-downloads the K/N
|
||||
# toolchain (downloadKotlinNativeDistribution) and the iOS-target compile
|
||||
# tasks it feeds miss the build cache, while the identical tasks hit
|
||||
# FROM-CACHE locally. Keyed on the version catalog: over-invalidates on
|
||||
# unrelated bumps, but konan re-downloads are exactly what it prevents.
|
||||
- name: Cache Kotlin/Native toolchain
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
# ~/.konan and ~/.m2 sit outside the Gradle User Home, so setup-gradle does not cache them.
|
||||
# A save step here would run before ./gradlew, so writers rely on actions/cache's end-of-job save.
|
||||
- name: Read the Kotlin version
|
||||
if: inputs.cache_konan == 'true'
|
||||
id: kotlin
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(awk -F'"' '/^kotlin *= *"/ { print $2; exit }' gradle/libs.versions.toml)
|
||||
if [ -z "$version" ]; then
|
||||
echo "::error file=gradle/libs.versions.toml::no kotlin version entry"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore the Kotlin/Native toolchain
|
||||
if: inputs.cache_konan == 'true' && inputs.cache_read_only == 'true'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.konan
|
||||
key: konan-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('gradle/libs.versions.toml') }}
|
||||
key: konan-${{ runner.os }}-${{ runner.arch }}-${{ steps.kotlin.outputs.version }}
|
||||
restore-keys: |
|
||||
konan-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# Robolectric downloads its android-all-instrumented jars to the local Maven
|
||||
# repo, which lives outside the Gradle User Home and so isn't covered by
|
||||
# setup-gradle's caching. (This was previously attempted via
|
||||
# gradle-home-cache-includes, but those entries resolve relative to the
|
||||
# Gradle User Home — the `~/.m2/...` line expanded to the literal path
|
||||
# `<gradle-home>/~/.m2/...` and never matched anything.) Keyed on the
|
||||
# version catalog, which pins the Robolectric version.
|
||||
- name: Cache Robolectric android-all jars
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
- name: Cache the Kotlin/Native toolchain
|
||||
if: inputs.cache_konan == 'true' && inputs.cache_read_only != 'true'
|
||||
id: konan
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.konan
|
||||
key: konan-${{ runner.os }}-${{ runner.arch }}-${{ steps.kotlin.outputs.version }}
|
||||
restore-keys: |
|
||||
konan-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# A restore-keys match brings the previous Kotlin's bundle along; without this the saved entry grows every bump.
|
||||
- name: Drop other Kotlin/Native bundles before the save
|
||||
if: inputs.cache_konan == 'true' && inputs.cache_read_only != 'true' && steps.konan.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
KOTLIN_VERSION: ${{ steps.kotlin.outputs.version }}
|
||||
run: |
|
||||
[ -d ~/.konan ] || exit 0
|
||||
find ~/.konan -mindepth 1 -maxdepth 1 -name 'kotlin-native-prebuilt-*' ! -name "kotlin-native-prebuilt-*-${KOTLIN_VERSION}" -print -exec rm -rf {} +
|
||||
|
||||
- name: Restore the Robolectric android-all jars
|
||||
if: inputs.cache_robolectric == 'true' && inputs.cache_read_only == 'true'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.m2/repository/org/robolectric
|
||||
key: robolectric-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('gradle/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
robolectric-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Cache the Robolectric android-all jars
|
||||
if: inputs.cache_robolectric == 'true' && inputs.cache_read_only != 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.m2/repository/org/robolectric
|
||||
key: robolectric-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('gradle/libs.versions.toml') }}
|
||||
@@ -107,7 +127,7 @@ runs:
|
||||
robolectric-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
|
||||
uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6
|
||||
with:
|
||||
cache-read-only: ${{ inputs.cache_read_only }}
|
||||
cache-encryption-key: ${{ inputs.gradle_encryption_key }}
|
||||
@@ -140,21 +160,3 @@ runs:
|
||||
# cache latency turns out to cost more than the restore it replaced.
|
||||
gradle-home-cache-excludes: |
|
||||
caches/build-cache-1
|
||||
|
||||
# CC entries live in the project dir; setup-gradle only caches the Gradle User Home —
|
||||
# Develocity measured 100% CC miss (~52s config/build) before this. Runs after Setup
|
||||
# Gradle so GRADLE_ENCRYPTION_KEY is exported. No sha in the key: unchanged build files
|
||||
# hit exactly and skip the save; restore-keys is the self-heal for stale entries.
|
||||
# The wrapper hash is its own restore-key segment: a stale same-version entry is a
|
||||
# graceful CC miss, but an entry written by a DIFFERENT Gradle version can crash
|
||||
# fingerprint deserialization outright (seen on 9.6.1 entries under 9.7.0).
|
||||
- name: Cache Gradle configuration-cache
|
||||
# Gates: opted in; key present (undecryptable otherwise — keeps keyless fork PRs off
|
||||
# these entries); not merge_group (throwaway cache scope).
|
||||
if: inputs.cache_configuration_cache == 'true' && inputs.gradle_encryption_key != '' && github.event_name != 'merge_group'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/.gradle/configuration-cache
|
||||
key: gradle-cc-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}${{ inputs.cache_key_suffix && format('-{0}', inputs.cache_key_suffix) || '' }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('settings.gradle.kts', '**/build.gradle.kts', 'build-logic/**', 'gradle/*.gradle', 'gradle/libs.versions.toml', 'gradle.properties', 'config.properties', '.github/ci-gradle.properties') }}
|
||||
restore-keys: |
|
||||
gradle-cc-${{ runner.os }}-${{ runner.arch }}-${{ github.job }}${{ inputs.cache_key_suffix && format('-{0}', inputs.cache_key_suffix) || '' }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }}-
|
||||
@@ -12,13 +12,17 @@
|
||||
org.gradle.daemon=false
|
||||
|
||||
# ── Memory ────────────────────────────────────────────────────────────
|
||||
# Public-repo ubuntu-24.04 runners have 16 GB RAM. Keep Gradle + Kotlin daemon
|
||||
# within budget (4g Gradle + 6g Kotlin daemon, leaving room for lint and K/N).
|
||||
# Public-repo hosted Ubuntu x64 runners have 16 GB RAM; each -Xmx is a ceiling, not a reservation.
|
||||
# Only kotlin.daemon.jvmargs is read from a properties file; kotlin.daemon.jvm.options
|
||||
# is a system property. Unset, the daemon inherits org.gradle.jvmargs' 4g and OOMs.
|
||||
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
|
||||
# is a system property. Unset, the daemon inherits org.gradle.jvmargs and OOMs.
|
||||
org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
|
||||
kotlin.daemon.jvmargs=-Xmx6g -XX:+UseG1GC
|
||||
|
||||
# ── Kotlin/Native ─────────────────────────────────────────────────────
|
||||
# Each K/N compile gets its own JVM with gradle.properties' kotlin.native.jvmArgs heap. In process,
|
||||
# koin-compiler-plugin 1.2.1 keeps every compile's IR reachable from the Gradle daemon until the build ends.
|
||||
kotlin.native.disableCompilerDaemon=true
|
||||
|
||||
# ── Parallelism ───────────────────────────────────────────────────────
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=4
|
||||
@@ -44,9 +48,6 @@ ksp.incremental=false
|
||||
|
||||
# ── Android ──────────────────────────────────────────────────────────
|
||||
android.experimental.lint.analysisPerComponent=true
|
||||
# Disable unused build features to reduce build time
|
||||
android.defaults.buildfeatures.resvalues=false
|
||||
android.defaults.buildfeatures.shaders=false
|
||||
|
||||
# ── Misc ─────────────────────────────────────────────────────────────
|
||||
org.gradle.welcome=never
|
||||
@@ -8,14 +8,14 @@ Requires JDK 25 and `ANDROID_HOME`. No secrets step: the secrets plugin reads
|
||||
`secrets.properties` and falls back to the tracked `secrets.defaults.properties`, so the
|
||||
`google` flavor builds without either. Add `secrets.properties` only for real Maps tiles.
|
||||
```bash
|
||||
./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests # full local verification (run before push)
|
||||
./gradlew spotlessApply spotlessCheck detekt detektTypeResolved assembleDebug test allTests # full local verification (run before push)
|
||||
./gradlew :core:data:allTests # single KMP module
|
||||
./gradlew :androidApp:testFdroidDebugUnitTest # single Android-only module
|
||||
./gradlew kmpSmokeCompile # cross-platform compile check, no tests
|
||||
```
|
||||
> Both `test` AND `allTests` are needed — `allTests` covers KMP modules, `test` covers pure-Android modules.
|
||||
|
||||
**KMP vs Android-only task naming** (wrong name silently skips tests or fails resolution): KMP modules (`core:*`, `feature:*`) use `:module:allTests` and `:module:compileKotlinJvm`; Android-only modules (`androidApp`, `desktopApp`, `core:barcode`) use `:module:testFdroidDebugUnitTest` (plain `:desktopApp:test` for the JVM-only desktop module). `:module:detekt` is the lifecycle task for both — never `detektMain`/`detektDebug`. Full matrix and pitfalls: `.skills/testing-ci/`.
|
||||
**KMP vs Android-only task naming** (wrong name silently skips tests or fails resolution): KMP modules (`core:*`, `feature:*`) use `:module:allTests` and `:module:compileKotlinJvm`; Android-only modules (`androidApp`, `desktopApp`, `core:barcode`) use `:module:testFdroidDebugUnitTest` (plain `:desktopApp:test` for the JVM-only desktop module). `:module:detekt` runs the rules that need no classpath and `:module:detektTypeResolved` the ones that do (both kinds of module); never call `detektMain`/`detektDebug` directly. Full matrix and pitfalls: `.skills/testing-ci/`.
|
||||
|
||||
Architecture, flavors, conventions, branch naming, protos, coding rules: **see `AGENTS.md`**. Contextual `.github/instructions/` files enforce conventions scoped to specific source sets.
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ applyTo: "build-logic/**/*.kt"
|
||||
- Prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs).
|
||||
- Avoid `afterEvaluate` unless there is no viable lazy alternative.
|
||||
- Check `gradle/libs.versions.toml` for version catalog aliases before adding new ones.
|
||||
- 24 convention plugin ids are registered in `build-logic/convention/build.gradle.kts` — read that block rather than guessing. The ones module builds apply most often: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.library.compose`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`.
|
||||
- 25 convention plugin ids are registered in `build-logic/convention/build.gradle.kts`. Read that block rather than guessing. The ones module builds apply most often: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.library.compose`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`.
|
||||
@@ -9,11 +9,14 @@ excludeAgent: "code-review"
|
||||
- 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 (status gates, labelers, triage, run-cancellers, changelog/release
|
||||
- Runner labels are named by tier here; the workflows carry the versions.
|
||||
- Desktop build matrix: `macos-latest`, `windows-latest`, and Ubuntu x64 and arm64; `build-desktop`
|
||||
in `reusable-check.yml` lists the labels.
|
||||
- Lightweight jobs off the required-check path (labelers, run-cancellers, changelog/release
|
||||
cleanup): use `ubuntu-slim`. It is container-backed and starts in seconds, but it is
|
||||
single-CPU, unprivileged, x64-only, and its 15-minute job cap is a hard platform limit — so it
|
||||
single-CPU, unprivileged, x64-only, and its 15-minute job cap is a hard platform limit. It
|
||||
fits API/script work (`gh`, `jq`, `git`, stdlib `python3`, `github-script`) and nothing that
|
||||
needs `sudo`, `apt-get`, Docker, a mounted filesystem, or a long full-history clone.
|
||||
- Lightweight jobs that break any of those constraints: use `ubuntu-24.04-arm` runners.
|
||||
- Gradle-heavy jobs: use `ubuntu-24.04` runners.
|
||||
- Lightweight jobs that break any of those constraints, and the required `Check Workflow Status`
|
||||
gates, which must not queue on slim's separate pool: use the pinned Ubuntu LTS arm label.
|
||||
- Gradle-heavy jobs: use the pinned Ubuntu LTS x64 label that `reusable-check.yml` uses.
|
||||
+62
-10
@@ -14,9 +14,17 @@
|
||||
"labels": [
|
||||
"dependencies"
|
||||
],
|
||||
"git-submodules": {
|
||||
"enabled": true
|
||||
},
|
||||
"ignorePaths": [
|
||||
"**/node_modules/**",
|
||||
"**/bower_components/**",
|
||||
"**/vendor/**",
|
||||
"**/examples/**",
|
||||
"**/__tests__/**",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"**/__fixtures__/**",
|
||||
".specify/**"
|
||||
],
|
||||
"bundler": {
|
||||
"enabled": true
|
||||
},
|
||||
@@ -51,12 +59,13 @@
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Meshtastic Protobufs changelog link",
|
||||
"description": "Protobufs bumps: link the upstream compare in the body (the snapshot version carries the commit as -g<sha>; a release maps to its tag). protobufs-bump.yml adds a comment with the merged upstream PRs, the .proto delta and the settings strings that change; the scheduled-updates run the catalog change triggers on main regenerates values/schema_strings.xml from whatever pin main has.",
|
||||
"matchPackageNames": [
|
||||
"https://github.com/meshtastic/protobufs.git"
|
||||
"org.meshtastic:protobufs"
|
||||
],
|
||||
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
|
||||
"automerge": true
|
||||
"prBodyNotes": [
|
||||
"Upstream: https://github.com/meshtastic/protobufs/compare/{{#if (containsString currentVersion 'SNAPSHOT')}}{{{replace '^.*-g([0-9a-f]+)-SNAPSHOT$' '$1' currentVersion}}}{{else}}v{{{currentVersion}}}{{/if}}...{{#if (containsString newVersion 'SNAPSHOT')}}{{{replace '^.*-g([0-9a-f]+)-SNAPSHOT$' '$1' newVersion}}}{{else}}v{{{newVersion}}}{{/if}} - the `Protobufs Bump` workflow comments with the merged PRs, the `.proto` delta and the settings strings that change; `values/schema_strings.xml` is regenerated by the scheduled-updates run this merge triggers."
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Protobufs: only accept the dot-form snapshot scheme (X.Y.Z.N-g<sha>-SNAPSHOT) or plain releases. Legacy hyphen-SHA snapshots (e.g. 2.7.26-678281c-SNAPSHOT) tokenize their digit-leading SHA as a huge int that outranks the dot-form commit-count in Renovate's maven comparator, so without this constraint Renovate keeps proposing a downgrade to an old snapshot (see PR #6229). The snapshot repo has no delete API; legacy versions auto-prune after 90 days.",
|
||||
@@ -85,9 +94,6 @@
|
||||
"/^org\\.jetbrains\\.kotlin/",
|
||||
"/^org\\.jetbrains\\.kotlinx/",
|
||||
"/^org\\.jetbrains\\.compose/",
|
||||
"/^com\\.google\\.dagger/",
|
||||
"/^androidx\\.hilt/",
|
||||
"/^com\\.google\\.protobuf/",
|
||||
"/^androidx\\.lifecycle/",
|
||||
"/^androidx\\.navigation/",
|
||||
"/^androidx\\.datastore/",
|
||||
@@ -116,6 +122,26 @@
|
||||
"/^io\\.insert-koin\\.compiler\\.plugin/"
|
||||
],
|
||||
"automerge": false
|
||||
},
|
||||
{
|
||||
"description": "One weekly PR for everything the github-actions manager finds, since each .github-only PR runs the whole PR pipeline. Majors still get their own PR. Ruby stays out so the setup-ruby inputs move with .ruby-version. Vulnerability fixes are not held: Renovate forces an empty schedule and no group on them.",
|
||||
"matchManagers": [
|
||||
"github-actions"
|
||||
],
|
||||
"matchDepNames": [
|
||||
"!ruby"
|
||||
],
|
||||
"groupName": "github-actions",
|
||||
"schedule": [
|
||||
"before 6am on monday"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "Ruby waits a week: setup-ruby learns a new Ruby some days after its release, and a bump that reaches main first breaks every job that installs Ruby. The depName covers .ruby-version and the setup-ruby ruby-version inputs alike.",
|
||||
"matchDepNames": [
|
||||
"ruby"
|
||||
],
|
||||
"minimumReleaseAge": "7 days"
|
||||
}
|
||||
],
|
||||
"customManagers": [
|
||||
@@ -131,6 +157,32 @@
|
||||
"depNameTemplate": "gradle",
|
||||
"datasourceTemplate": "gradle-version",
|
||||
"versioningTemplate": "gradle"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "ktlint's version is a bare [versions] entry that only Spotless reads, so the gradle manager cannot tie it to an artifact.",
|
||||
"managerFilePatterns": [
|
||||
"/^gradle/libs\\.versions\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"\\nktlint\\s*=\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "com.pinterest.ktlint:ktlint-cli",
|
||||
"datasourceTemplate": "maven",
|
||||
"versioningTemplate": "maven"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "ktfmt's version is a bare [versions] entry that only Spotless reads, so the gradle manager cannot tie it to an artifact.",
|
||||
"managerFilePatterns": [
|
||||
"/^gradle/libs\\.versions\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"\\nktfmt\\s*=\\s*\"(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "com.facebook:ktfmt",
|
||||
"datasourceTemplate": "maven",
|
||||
"versioningTemplate": "maven"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -28,8 +28,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
@@ -41,13 +39,14 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
determine-tags:
|
||||
runs-on: ubuntu-26.04-arm
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
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 }}
|
||||
version_name: ${{ steps.version.outputs.version_name }}
|
||||
version_code: ${{ steps.version.outputs.version_code }}
|
||||
steps:
|
||||
# Internal releases are exempt: Play internal testing skips full review,
|
||||
# so only promotions (closed/open/production) can clobber an in-flight
|
||||
@@ -66,9 +65,14 @@ jobs:
|
||||
|
||||
- name: Calculate tags
|
||||
id: calculate_tags
|
||||
env:
|
||||
BASE_VERSION: ${{ inputs.base_version }}
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
run: |
|
||||
BASE_VERSION="${{ inputs.base_version }}"
|
||||
CHANNEL="${{ inputs.channel }}"
|
||||
if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::base_version '$BASE_VERSION' is not X.Y.Z (e.g., 2.8.3)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$CHANNEL" == "internal" ]]; then
|
||||
# This is a new build, create a new internal tag
|
||||
@@ -84,7 +88,6 @@ jobs:
|
||||
echo "Calculated new tag: $NEW_TAG"
|
||||
{
|
||||
echo "tag_to_process=$NEW_TAG"
|
||||
echo "release_name=$NEW_TAG"
|
||||
echo "final_tag=$NEW_TAG"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
@@ -121,17 +124,49 @@ jobs:
|
||||
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}"
|
||||
echo "tag_to_process=${LATEST_TAG_TO_PROMOTE}"
|
||||
echo "release_name=${NEW_TAG}"
|
||||
echo "final_tag=${NEW_TAG}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
# Name from the tag, code from the commit count plus VERSION_CODE_OFFSET, both at the
|
||||
# commit being released: for a promotion the promoted tag's, which main has moved past.
|
||||
- name: Calculate version
|
||||
id: version
|
||||
env:
|
||||
BASE_VERSION: ${{ inputs.base_version }}
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
TAG: ${{ steps.calculate_tags.outputs.tag_to_process }}
|
||||
run: |
|
||||
if [[ "$CHANNEL" == "internal" ]]; then REF=HEAD; else REF="$TAG"; fi
|
||||
git show "${REF}:config.properties" > "$RUNNER_TEMP/config.properties"
|
||||
|
||||
# The release highlights and the Play what's-new are looked up by VERSION_NAME_BASE.
|
||||
CONFIG_BASE=$(sed -n 's/^VERSION_NAME_BASE=//p' "$RUNNER_TEMP/config.properties")
|
||||
if [[ "$CONFIG_BASE" != "$BASE_VERSION" ]]; then
|
||||
echo "::error::base_version is $BASE_VERSION but config.properties at $REF has VERSION_NAME_BASE=${CONFIG_BASE:-<unset>}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION_NAME=$(echo "$TAG" | sed 's/-.*//' | sed 's/v//')
|
||||
VERSION_CODE_OFFSET=$(grep '^VERSION_CODE_OFFSET=' "$RUNNER_TEMP/config.properties" | cut -d'=' -f2 || true)
|
||||
if ! [[ "$VERSION_CODE_OFFSET" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::VERSION_CODE_OFFSET from config.properties is not numeric: '$VERSION_CODE_OFFSET'"
|
||||
exit 1
|
||||
fi
|
||||
VERSION_CODE=$(( $(git rev-list --count "$REF") + VERSION_CODE_OFFSET ))
|
||||
|
||||
echo "Version: $VERSION_NAME ($VERSION_CODE) from $REF"
|
||||
{
|
||||
echo "version_name=$VERSION_NAME"
|
||||
echo "version_code=$VERSION_CODE"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
|
||||
- name: Create and Push Release Tag
|
||||
if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
|
||||
env:
|
||||
@@ -148,10 +183,8 @@ jobs:
|
||||
uses: ./.github/workflows/release.yml
|
||||
with:
|
||||
tag_name: ${{ needs.determine-tags.outputs.final_tag }}
|
||||
channel: ${{ inputs.channel }}
|
||||
base_version: ${{ inputs.base_version }}
|
||||
build_desktop: true
|
||||
build_flatpak_src: true
|
||||
version_name: ${{ needs.determine-tags.outputs.version_name }}
|
||||
version_code: ${{ needs.determine-tags.outputs.version_code }}
|
||||
secrets: inherit
|
||||
|
||||
call-promote-workflow:
|
||||
@@ -164,38 +197,65 @@ jobs:
|
||||
# so call-release-workflow doesn't carry it.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
actions: write
|
||||
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 }}
|
||||
version_code: ${{ needs.determine-tags.outputs.version_code }}
|
||||
secrets: inherit
|
||||
|
||||
# A production promotion stamps CHANGELOG.md itself; every other cut refreshes [Unreleased].
|
||||
update-changelog:
|
||||
needs: [call-release-workflow, call-promote-workflow]
|
||||
if: >-
|
||||
${{ !cancelled() && !inputs.dry_run && inputs.channel != 'production'
|
||||
&& (needs.call-release-workflow.result == 'success' || needs.call-promote-workflow.result == 'success') }}
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Dispatch Update Changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: gh workflow run update-changelog.yml --ref main
|
||||
|
||||
cleanup-on-failure:
|
||||
needs: [determine-tags, call-release-workflow]
|
||||
if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }}
|
||||
runs-on: ubuntu-26.04-arm
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Delete Failed or Cancelled Tag
|
||||
env:
|
||||
FINAL_TAG: ${{ needs.determine-tags.outputs.final_tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
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
|
||||
if [ -z "$FINAL_TAG" ]; then
|
||||
echo "No tag was created to delete."
|
||||
exit 0
|
||||
fi
|
||||
# Play keeps an uploaded versionCode, so once publish-play succeeded the tag stays with it.
|
||||
PLAY=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
||||
--jq '[.jobs[] | select(.name | endswith("publish-play")) | .conclusion][0] // ""') || {
|
||||
echo "::warning::Could not read this run's jobs, so $FINAL_TAG stays. Delete it by hand if Play has no build from it."
|
||||
exit 0
|
||||
}
|
||||
if [ "$PLAY" = "success" ]; then
|
||||
echo "::warning::Play already has the build from $FINAL_TAG, so the tag stays. Re-run the failed jobs to finish the release."
|
||||
exit 0
|
||||
fi
|
||||
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."
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Submit Dependency Graph
|
||||
|
||||
# PR runs can only generate-and-upload (fork tokens lack contents: write). This submits what
|
||||
# they saved, from the base repo's trusted context. Never checks out PR code — the snapshot
|
||||
# artifact is the only input.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Pull Request CI']
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
# head_branch alone would collide across forks that share a branch name (e.g. two "patch-1"s).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
submit-dependency-graph:
|
||||
# failed runs may have partial graphs; skip
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
# skipped android-check (docs-only/bot PRs) uploads nothing — don't fail red on that
|
||||
- name: Check the run saved a dependency graph
|
||||
id: probe
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
count=$(gh api --paginate "repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}/artifacts?per_page=100" \
|
||||
--jq '[.artifacts[] | select(.name | startswith("dependency-graph"))] | length' | paste -sd+ | bc)
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download and submit dependency graph
|
||||
if: steps.probe.outputs.count != '0'
|
||||
uses: gradle/actions/dependency-submission@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
|
||||
with:
|
||||
dependency-graph: download-and-submit
|
||||
@@ -1,29 +1,23 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
# Publishes the main-branch docs snapshot to /main/ (and refreshed Dokka to
|
||||
# /api/) on the persistent gh-pages branch. The site root (latest release)
|
||||
# and /vX.Y.Z/ folders are owned by docs-release.yml and are left untouched.
|
||||
# Publishes the main-branch docs to the persistent gh-pages branch: the site
|
||||
# snapshot at /main/ on pushes that touch a site input, and the Dokka reference
|
||||
# at /api/ on the daily schedule. A manual run rebuilds both. The site root and
|
||||
# /vX.Y.Z/ folders are owned by docs-release.yml and are left untouched.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
# Dokka sources (KDoc in source files)
|
||||
- 'androidApp/src/**'
|
||||
- 'core/**/src/**'
|
||||
- 'feature/**/src/**'
|
||||
- 'desktopApp/src/**'
|
||||
# Docs site sources
|
||||
- 'docs/**'
|
||||
- 'feature/docs/**'
|
||||
# Build infrastructure. Module scripts are included because they can add or
|
||||
# drop exported `api` dependencies and reshape source sets, changing the
|
||||
# generated reference without any edit under src/.
|
||||
- 'build-logic/**'
|
||||
- '**/build.gradle.kts'
|
||||
- 'settings.gradle.kts'
|
||||
- '.github/workflows/docs-deploy.yml'
|
||||
- 'build-logic/convention/**/Docs*.kt'
|
||||
- 'specs/20260507-161858-app-docs-markdown/contracts/keyword-index-schema.json'
|
||||
- 'scripts/docs/**'
|
||||
- '.ruby-version'
|
||||
- '.github/workflows/docs-deploy.yml'
|
||||
|
||||
schedule:
|
||||
- cron: '37 5 * * *'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -42,92 +36,68 @@ jobs:
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
BUILD_SITE: ${{ github.event_name != 'schedule' }}
|
||||
BUILD_API: ${{ github.event_name != 'push' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
# Dokka runs commonizeNativeDistribution, which reads ~/.konan.
|
||||
cache_konan: 'true'
|
||||
cache_robolectric: 'false'
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
if: env.BUILD_SITE == 'true'
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
# With ruby-version unset this reads the root .ruby-version; the bundle is docs/Gemfile.
|
||||
env:
|
||||
BUNDLE_GEMFILE: docs/Gemfile
|
||||
with:
|
||||
ruby-version: '4.0.7'
|
||||
bundler-cache: true
|
||||
working-directory: docs
|
||||
|
||||
# Dokka is the slowest part of this workflow (~14 min) but KDoc changes far
|
||||
# less often than the prose docs. Rebuild /api/ only when something that can
|
||||
# actually change the generated reference was touched; otherwise the existing
|
||||
# /api/ on gh-pages is left in place (the publisher overlays per channel).
|
||||
# Manual runs always rebuild everything, so the diff is only needed on push.
|
||||
- name: Detect Dokka-relevant changes
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4
|
||||
id: filter
|
||||
with:
|
||||
token: ''
|
||||
filters: |
|
||||
# Positive patterns only. paths-filter evaluates each pattern as an
|
||||
# independent predicate and ORs them together, so a '!excluded/**'
|
||||
# entry would match every path outside that dir and make the filter
|
||||
# always true. The modules in DOKKA_EXCLUDED_MODULES that live under
|
||||
# these prefixes (:core:konsist) therefore still trigger a rebuild;
|
||||
# they change rarely enough that the odd wasted run is fine.
|
||||
dokka:
|
||||
- 'androidApp/src/**'
|
||||
- 'core/**/src/**'
|
||||
- 'feature/**/src/**'
|
||||
- 'desktopApp/src/**'
|
||||
# Dokka config, module list and the plugin classpath. '**/build.gradle.kts'
|
||||
# covers the root script as well as every module's, since a module script
|
||||
# can change exported `api` deps or source sets with no src/ edit.
|
||||
- 'build-logic/**'
|
||||
- '**/build.gradle.kts'
|
||||
- 'settings.gradle.kts'
|
||||
- 'gradle/libs.versions.toml'
|
||||
|
||||
- name: Generate Docs Site (main channel)
|
||||
if: env.BUILD_SITE == 'true'
|
||||
run: ./gradlew generateDocsBundle validateDocsBundle publishDocsSite -Pdocs.channel=main -Pci=true
|
||||
|
||||
# Dokka (Gradle) and Jekyll (Ruby) are independent — Dokka's output is only
|
||||
# copied in afterwards — so run them concurrently to overlap the two slowest
|
||||
# steps (~14 min Dokka vs ~5 min Jekyll) instead of summing them.
|
||||
- name: Build Dokka + Jekyll concurrently
|
||||
env:
|
||||
BUILD_DOKKA: ${{ steps.filter.outputs.dokka == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
# Jekyll reads only the generated site and Dokka only the sources, so a run
|
||||
# that builds both overlaps them.
|
||||
- name: Build Jekyll and Dokka
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build \
|
||||
--source build/_site/main \
|
||||
--destination build/jekyll_site \
|
||||
--baseurl /${{ github.event.repository.name }}/main &
|
||||
jekyll_pid=$!
|
||||
if [ "$BUILD_DOKKA" = "true" ]; then
|
||||
./gradlew dokkaGeneratePublicationHtml
|
||||
else
|
||||
echo "No Dokka-relevant changes — skipping API reference rebuild."
|
||||
jekyll_pid=""
|
||||
if [ "$BUILD_SITE" = "true" ]; then
|
||||
BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build \
|
||||
--source build/_site/main \
|
||||
--destination build/jekyll_site \
|
||||
--baseurl "/${GITHUB_REPOSITORY#*/}/main" &
|
||||
jekyll_pid=$!
|
||||
fi
|
||||
if [ "$BUILD_API" = "true" ]; then
|
||||
./gradlew :dokkaGeneratePublicationHtml
|
||||
fi
|
||||
if [ -n "$jekyll_pid" ]; then
|
||||
wait "$jekyll_pid"
|
||||
fi
|
||||
wait "$jekyll_pid"
|
||||
|
||||
- name: Stage channels
|
||||
id: stage
|
||||
env:
|
||||
BUILD_DOKKA: ${{ steps.filter.outputs.dokka == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p build/pages_staging
|
||||
cp -r build/jekyll_site build/pages_staging/main
|
||||
channels="main"
|
||||
if [ "$BUILD_DOKKA" = "true" ]; then
|
||||
channels=""
|
||||
if [ "$BUILD_SITE" = "true" ]; then
|
||||
cp -r build/jekyll_site build/pages_staging/main
|
||||
channels="main"
|
||||
fi
|
||||
if [ "$BUILD_API" = "true" ]; then
|
||||
cp -r build/dokka/html build/pages_staging/api
|
||||
channels="$channels api"
|
||||
channels="${channels:+$channels }api"
|
||||
fi
|
||||
echo "channels=$channels" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Docs Link Check
|
||||
|
||||
# Checks the web links in docs/en every week and keeps one open issue listing
|
||||
# the broken ones. docs-quality.yml checks the internal links on every docs PR.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '23 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# One run at a time, so two runs can never both find no issue and open two.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# http(s) only: Jekyll's extensionless page links are not files lychee can
|
||||
# resolve. Discord rate-limits invite lookups, and a 429 is not a broken link.
|
||||
- name: Check links
|
||||
id: lychee
|
||||
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
|
||||
with:
|
||||
fail: false
|
||||
args: >-
|
||||
--no-progress
|
||||
--scheme https
|
||||
--scheme http
|
||||
--max-retries 3
|
||||
--accept '100..=103,200..=299,429'
|
||||
--exclude '^https?://(www\.)?discord\.(gg|com)/'
|
||||
'docs/en/**/*.md'
|
||||
|
||||
# lychee exits 2 when links are broken; any other non-zero code is the
|
||||
# check itself failing, which fails the run instead of filing an issue.
|
||||
- name: Open or update the broken-links issue
|
||||
if: steps.lychee.outputs.exit_code != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EXIT_CODE: ${{ steps.lychee.outputs.exit_code }}
|
||||
TITLE: Broken links in docs/en
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EXIT_CODE" != "2" ]; then
|
||||
echo "lychee exited $EXIT_CODE; see the Check links step." >&2
|
||||
exit 1
|
||||
fi
|
||||
{ printf 'Found by the weekly link check: %s\n\n' "$RUN_URL"; cat lychee/out.md; } > "$RUNNER_TEMP/body.md"
|
||||
number=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --label documentation \
|
||||
--search "\"$TITLE\" in:title" --json number,title \
|
||||
--jq '[.[] | select(.title == env.TITLE)][0].number // empty')
|
||||
if [ -n "$number" ]; then
|
||||
gh issue edit "$number" --repo "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/body.md"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label documentation \
|
||||
--body-file "$RUNNER_TEMP/body.md"
|
||||
fi
|
||||
|
||||
- name: Close the broken-links issue
|
||||
if: steps.lychee.outputs.exit_code == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TITLE: Broken links in docs/en
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
number=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --label documentation \
|
||||
--search "\"$TITLE\" in:title" --json number,title \
|
||||
--jq '[.[] | select(.title == env.TITLE)][0].number // empty')
|
||||
if [ -n "$number" ]; then
|
||||
gh issue close "$number" --repo "$GITHUB_REPOSITORY" --comment "Every link passed in $RUN_URL."
|
||||
fi
|
||||
@@ -13,7 +13,9 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docs/en/**"
|
||||
- "docs/**/*.md"
|
||||
- "feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt"
|
||||
- "scripts/docs/sync-locale-front-matter.py"
|
||||
- "scripts/check-doc-aliases.js"
|
||||
- "scripts/check-doc-coverage.js"
|
||||
- "scripts/check-doc-freshness.js"
|
||||
@@ -103,6 +105,11 @@ jobs:
|
||||
# frontmatter and never registered is a search term no consumer ever sees.
|
||||
run: node scripts/check-doc-aliases.js .
|
||||
|
||||
- name: Check locale front matter
|
||||
# layout and nav_order in every docs/<locale>/ page must match docs/en, and no locale
|
||||
# page may have a parent or grand_parent, which would list it in an English section.
|
||||
run: python3 scripts/docs/sync-locale-front-matter.py --check
|
||||
|
||||
- name: Check doc freshness
|
||||
# Advisory, as it always was: a page being old is a prompt to look, not a defect.
|
||||
continue-on-error: true
|
||||
|
||||
@@ -2,23 +2,24 @@ name: Docs Release
|
||||
|
||||
# Publishes release docs to the persistent gh-pages branch.
|
||||
#
|
||||
# Production tags (vX.Y.Z) own the site root and get a permanent /vX.Y.Z/ copy,
|
||||
# plus a refreshed Dokka reference at /api/.
|
||||
# promote.yml dispatches this on the tag after every open, closed and production
|
||||
# promotion, because a tag created with GITHUB_TOKEN starts no workflow. The tag
|
||||
# trigger covers a tag pushed by hand, and a manual run against a tag ref
|
||||
# (re)publishes that version without cutting a new tag.
|
||||
#
|
||||
# Production tags (vX.Y.Z) own the site root and get a /vX.Y.Z/ copy, plus a
|
||||
# refreshed Dokka reference at /api/.
|
||||
#
|
||||
# Open- and closed-testing tags (vX.Y.Z-open.N / vX.Y.Z-closed.N) publish a
|
||||
# per-tag snapshot at /vX.Y.Z-open.N/ only. They deliberately do NOT touch the
|
||||
# root or /api/: the root belongs to production releases, and /api/ is an
|
||||
# unversioned channel already kept current by docs-deploy.yml on every push to
|
||||
# main — rebuilding Dokka (~14 min) for each of the many prerelease tags in a
|
||||
# cycle would cost far more than it refreshes.
|
||||
# unversioned channel that docs-deploy.yml rebuilds from main every day.
|
||||
#
|
||||
# These per-tag prerelease directories accumulate during a version cycle and are
|
||||
# reaped by post-release-cleanup.yml once the production vX.Y.Z tag ships.
|
||||
# reaped by post-release-cleanup.yml, which a production publish dispatches. That
|
||||
# cleanup also removes every production copy older than the newest one below /vX.Y.Z/.
|
||||
#
|
||||
# The /main/ snapshot is owned by docs-deploy.yml and is left untouched here.
|
||||
#
|
||||
# workflow_dispatch exists for backfill: run it against a tag ref to (re)publish
|
||||
# that version without cutting a new tag.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -43,12 +44,14 @@ jobs:
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 45
|
||||
# actions: write is only for the cleanup dispatch; job permissions replace the
|
||||
# workflow block, so contents: write is restated.
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
|
||||
# Resolves the tag into: the docs version label (which becomes the
|
||||
# published directory name) and whether this is a production release.
|
||||
@@ -84,13 +87,17 @@ jobs:
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
# Dokka runs commonizeNativeDistribution, which reads ~/.konan.
|
||||
cache_konan: 'true'
|
||||
cache_robolectric: 'false'
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
# With ruby-version unset this reads the root .ruby-version; the bundle is docs/Gemfile.
|
||||
env:
|
||||
BUNDLE_GEMFILE: docs/Gemfile
|
||||
with:
|
||||
ruby-version: '4.0.7'
|
||||
bundler-cache: true
|
||||
working-directory: docs
|
||||
|
||||
# Versioned docs (/vX.Y.Z/ or /vX.Y.Z-open.N/) — built for every channel.
|
||||
- name: Build Versioned Docs
|
||||
@@ -99,34 +106,39 @@ jobs:
|
||||
# Root site (/) — production releases only.
|
||||
- name: Build Root Docs Site
|
||||
if: steps.version.outputs.is_production == 'true'
|
||||
run: ./gradlew generateDocsBundle publishDocsSite -Pdocs.channel=root -Pci=true
|
||||
run: ./gradlew publishDocsSite -Pdocs.channel=root -Pci=true
|
||||
|
||||
# Dokka API reference (/api/) — production releases only.
|
||||
- name: Build Dokka HTML documentation
|
||||
if: steps.version.outputs.is_production == 'true'
|
||||
run: ./gradlew dokkaGeneratePublicationHtml
|
||||
|
||||
- name: Compile Jekyll Sites
|
||||
# The versioned source leaves build/_site/ first so the root site does not
|
||||
# nest it. The Jekyll builds read only the generated sites and Dokka (the
|
||||
# /api/ reference, production only) only the sources, so all three overlap.
|
||||
- name: Build Jekyll Sites and Dokka
|
||||
env:
|
||||
DOCS_VERSION: ${{ steps.version.outputs.docs_version }}
|
||||
IS_PRODUCTION: ${{ steps.version.outputs.is_production }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Versioned site
|
||||
BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build \
|
||||
--source "build/_site/v${DOCS_VERSION}" \
|
||||
--destination build/jekyll_release \
|
||||
--baseurl "/${{ github.event.repository.name }}/v${DOCS_VERSION}"
|
||||
|
||||
# Move the versioned source out of the root source tree so the root
|
||||
# build below doesn't try to nest it.
|
||||
site_name="${GITHUB_REPOSITORY#*/}"
|
||||
mv "build/_site/v${DOCS_VERSION}" build/v_temp
|
||||
|
||||
BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build \
|
||||
--source build/v_temp \
|
||||
--destination build/jekyll_release \
|
||||
--baseurl "/${site_name}/v${DOCS_VERSION}" &
|
||||
release_pid=$!
|
||||
|
||||
root_pid=""
|
||||
if [ "$IS_PRODUCTION" = "true" ]; then
|
||||
BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build \
|
||||
--source build/_site \
|
||||
--destination build/jekyll_root \
|
||||
--baseurl "/${{ github.event.repository.name }}"
|
||||
--baseurl "/${site_name}" &
|
||||
root_pid=$!
|
||||
./gradlew :dokkaGeneratePublicationHtml
|
||||
fi
|
||||
|
||||
wait "$release_pid"
|
||||
if [ -n "$root_pid" ]; then
|
||||
wait "$root_pid"
|
||||
fi
|
||||
|
||||
- name: Stage channels
|
||||
@@ -148,3 +160,12 @@ jobs:
|
||||
|
||||
- name: Publish to gh-pages
|
||||
run: scripts/docs/publish-to-gh-pages.sh build/pages_staging ${{ steps.stage.outputs.channels }}
|
||||
|
||||
# The cleanup refuses to reap until /vX.Y.Z/ is on gh-pages, which the push above
|
||||
# just made true. A dispatch is exempt from GITHUB_TOKEN's event suppression.
|
||||
- name: Dispatch post-release cleanup
|
||||
if: steps.version.outputs.is_production == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_VERSION: ${{ steps.version.outputs.docs_version }}
|
||||
run: gh workflow run post-release-cleanup.yml --ref main -f "base_version=$BASE_VERSION" -f confirm_deletion=true
|
||||
@@ -6,13 +6,16 @@ on:
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
- 'fastlane/**'
|
||||
- 'obtainium/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Not cancelled: one run plus one pending per ref, so every run that starts finishes.
|
||||
concurrency:
|
||||
group: main-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Every commit on main arrives via the merge queue, which already ran lint,
|
||||
|
||||
@@ -8,28 +8,34 @@ permissions:
|
||||
contents: read
|
||||
|
||||
# Note: github.ref is unique per merge-group entry (gh-readonly-queue/main/pr-N-<sha>),
|
||||
# so this group never dedupes across re-queues of the same PR — the cancel-superseded
|
||||
# job below handles that explicitly.
|
||||
# so this group never dedupes across re-queues of the same PR. check-changes cancels
|
||||
# those runs itself.
|
||||
concurrency:
|
||||
group: build-mq-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# When a PR is re-queued (an entry ahead of it failed or was removed), GitHub creates a
|
||||
# new merge group but does NOT cancel the workflow runs of the destroyed one. Those stale
|
||||
# runs sit queued/running and starve the runner pool. Cancel any older merge-queue run
|
||||
# for the same PR — only the newest merge group per PR is ever valid.
|
||||
# No checkout, no toolchain — just gh api + jq. Runs on the same label as check-changes:
|
||||
# ubuntu-slim is a separate, smaller pool, and a gate job waiting on it holds the whole queue.
|
||||
cancel-superseded:
|
||||
name: Cancel Superseded Queue Runs
|
||||
# Docs-only queue entries (changelog updates, markdown fixes, store listings, Obtainium
|
||||
# configs) cannot affect the build; skip the heavy pipeline for them. Anything outside
|
||||
# docs/, fastlane/, obtainium/ and *.md runs full CI. Mirrors the paths-ignore list in
|
||||
# main-check.yml.
|
||||
# No checkout, no toolchain: gh api only.
|
||||
check-changes:
|
||||
name: Check Changes
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
outputs:
|
||||
android: ${{ steps.filter.outputs.android }}
|
||||
steps:
|
||||
# A re-queued PR gets a new merge group, but GitHub leaves the destroyed group's runs
|
||||
# queued or running, starving the runner pool. Only the newest group per PR is valid.
|
||||
# Best effort: a failed cancel must not fail the required check.
|
||||
- name: Cancel older merge-queue runs for the same PR
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -50,28 +56,18 @@ jobs:
|
||||
gh api -X POST "repos/${{ github.repository }}/actions/runs/${run_id}/force-cancel" || true
|
||||
done
|
||||
|
||||
# Docs-only queue entries (changelog updates, markdown fixes) cannot affect the build;
|
||||
# skip the heavy pipeline for them. Anything outside docs/ and *.md runs full CI.
|
||||
# Mirrors the paths-ignore list in main-check.yml.
|
||||
check-changes:
|
||||
name: Check Changes
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
android: ${{ steps.filter.outputs.android }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Diff merge group against its base
|
||||
id: filter
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
git fetch --depth=1 origin "${{ github.event.merge_group.base_sha }}"
|
||||
changed=$(git diff --name-only "${{ github.event.merge_group.base_sha }}" "${{ github.event.merge_group.head_sha }}")
|
||||
echo "Changed files:"
|
||||
changed=$(gh api "repos/${{ github.repository }}/compare/${{ github.event.merge_group.base_sha }}...${{ github.event.merge_group.head_sha }}" \
|
||||
--jq '.files[].filename')
|
||||
count=$(grep -c . <<<"$changed" || true)
|
||||
echo "Changed files ($count):"
|
||||
echo "$changed"
|
||||
if echo "$changed" | grep -qvE '^docs/|\.md$|^$'; then
|
||||
# The compare API lists at most 300 files, so a list that long may be truncated.
|
||||
if [ "$count" -ge 300 ] || echo "$changed" | grep -qvE '^docs/|^fastlane/|^obtainium/|\.md$|^$'; then
|
||||
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||
@@ -99,14 +95,16 @@ jobs:
|
||||
upload_artifacts: false
|
||||
secrets: inherit
|
||||
|
||||
# Pure gate job: no checkout, no toolchain, just reads `needs` results. It is the required
|
||||
# check, so it runs on the label the rest of the workflow already gets slots on, not on
|
||||
# ubuntu-slim's separate pool: an aggregator queued behind slim blocks a finished build.
|
||||
# Gate job: no checkout, no toolchain, reads `needs` results and posts license/cla. It is the
|
||||
# required check, so it runs on a hosted Ubuntu label that shares the org's pool with the build
|
||||
# jobs, not on ubuntu-slim's separate pool: an aggregator queued behind slim blocks a finished build.
|
||||
check-workflow-status:
|
||||
name: Check Workflow Status
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
permissions:
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
needs:
|
||||
- check-changes
|
||||
- android-check
|
||||
@@ -118,8 +116,35 @@ jobs:
|
||||
echo "::error::Change detection failed"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${{ needs.check-changes.outputs.android }}" == "true" && ("${{ needs.android-check.result }}" == "failure" || "${{ needs.android-check.result }}" == "cancelled") ]]; then
|
||||
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"
|
||||
|
||||
# license/cla is required on the group commit too, but cla-assistant.io only checks PRs and
|
||||
# its placeholder for the group never arrives when its webhook drops, stalling the queue.
|
||||
- name: Post license/cla for the merge group
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
HEAD_SHA: ${{ github.event.merge_group.head_sha }}
|
||||
run: |
|
||||
if [[ ! "$REF_NAME" =~ /pr-([0-9]+)- ]]; then
|
||||
echo "::error::Could not parse PR from ref '$REF_NAME'"
|
||||
exit 1
|
||||
fi
|
||||
pr="${BASH_REMATCH[1]}"
|
||||
pr_sha=$(gh api "repos/$REPO/pulls/$pr" --jq '.head.sha')
|
||||
cla=$(gh api "repos/$REPO/commits/$pr_sha/status" \
|
||||
--jq '[.statuses[] | select(.context == "license/cla")][0].state // "missing"')
|
||||
if [[ "$cla" != "success" ]]; then
|
||||
echo "::error::license/cla is $cla on PR #$pr head $pr_sha"
|
||||
exit 1
|
||||
fi
|
||||
gh api -X POST "repos/$REPO/statuses/$HEAD_SHA" \
|
||||
-f state=success -f context=license/cla \
|
||||
-f "target_url=https://cla-assistant.io/$REPO" \
|
||||
-f "description=CLA signed on PR #$pr. CLA checks only happen on pull requests." \
|
||||
--jq '"posted \(.context) \(.state) on \(.url)"'
|
||||
@@ -46,6 +46,13 @@ jobs:
|
||||
# than silently skipping.
|
||||
HAS_MSSTORE_CREDS: ${{ secrets.MSSTORE_PRODUCT_ID != '' && 'true' || 'false' }}
|
||||
steps:
|
||||
# A skipped submission must not read as a published one.
|
||||
- name: Report an unconfigured Store publish
|
||||
if: env.HAS_MSSTORE_CREDS != 'true'
|
||||
run: |
|
||||
echo "::warning::MSSTORE_PRODUCT_ID is not set; nothing was submitted to the Microsoft Store."
|
||||
echo "- Microsoft Store: skipped, MSSTORE_* secrets not set" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The Store requires a versioned, immutable installer URL — per-tag
|
||||
# GitHub release asset URLs are exactly that. Fails loudly if the
|
||||
# release carries no MSI (e.g. the Windows build leg failed) rather
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
name: Play Store Listing
|
||||
|
||||
# Uploads fastlane/metadata/android - every locale's title, descriptions,
|
||||
# feature graphic, icon and screenshots - to the Google Play listing. Manual
|
||||
# only: a listing commit is a Play submission, and a submission sent for review
|
||||
# cancels and restarts any release review in flight. Nothing here touches a
|
||||
# build or a track.
|
||||
# feature graphic, icon and screenshots - to the Google Play listing from the
|
||||
# committed tree. Every promotion does this from the tag (promote.yml), so this
|
||||
# is the hand path for a listing change between releases. A listing commit is a
|
||||
# Play submission, and a submission sent for review cancels and restarts any
|
||||
# release review in flight. Nothing here touches a build or a track.
|
||||
#
|
||||
# The default is a dry run. Play validates the whole edit (unknown locales,
|
||||
# overlength text, bad image dimensions) and discards it; tick `publish` to
|
||||
@@ -43,13 +44,14 @@ jobs:
|
||||
run: python3 scripts/check-store-metadata.py
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
with:
|
||||
ruby-version: '4.0.7'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Decode Play Store credentials
|
||||
run: echo '${{ secrets.GOOGLE_PLAY_JSON_KEY }}' > fastlane/play-store-credentials.json
|
||||
env:
|
||||
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
|
||||
run: printf '%s\n' "$GOOGLE_PLAY_JSON_KEY" > fastlane/play-store-credentials.json
|
||||
|
||||
- name: Upload listing
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
name: Play Rollout
|
||||
|
||||
# Moves the staged rollout already on a Play track: the one inProgress release a promotion
|
||||
# leaves there (production at 10%, beta at 50%). rollout widens it to `fraction`, complete
|
||||
# ships it to every user, halt stops it where it is. supply acts only on inProgress releases,
|
||||
# so a halted release is resumed or completed in the Play Console.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
track:
|
||||
description: 'Play track holding the staged release'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- production
|
||||
- beta
|
||||
action:
|
||||
description: 'rollout widens it to fraction, complete ships it to everyone, halt stops it'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- rollout
|
||||
- complete
|
||||
- halt
|
||||
fraction:
|
||||
description: 'rollout only: the new user fraction, above the current one and below 1 (e.g., 0.5)'
|
||||
required: false
|
||||
type: string
|
||||
no_review_in_flight:
|
||||
description: 'I checked Publishing overview > Submission activity and no submission is In review. A committed rollout change CANCELS and RESTARTS any review in flight.'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Not the promotion's group: a group keeps one pending run, so this would cancel a pending promotion.
|
||||
concurrency:
|
||||
group: play-rollout
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
rollout:
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
TRACK: ${{ inputs.track }}
|
||||
ACTION: ${{ inputs.action }}
|
||||
steps:
|
||||
- name: Require review-in-flight confirmation
|
||||
if: ${{ !inputs.no_review_in_flight }}
|
||||
run: |
|
||||
echo "::error::Rollout change blocked: confirm no Play review is in flight. Check Play Console > Publishing overview > Submission activity; if a submission shows 'In review', wait. If clear, re-dispatch with 'no_review_in_flight' checked."
|
||||
exit 1
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Decode Play Store credentials
|
||||
env:
|
||||
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
|
||||
run: printf '%s\n' "$GOOGLE_PLAY_JSON_KEY" > fastlane/play-store-credentials.json
|
||||
|
||||
# Exactly one inProgress release, or nothing is changed: that release's version code
|
||||
# pins supply's selection, and its fraction is what a halt keeps.
|
||||
- name: Find the staged release
|
||||
id: staged
|
||||
env:
|
||||
FRACTION: ${{ inputs.fraction }}
|
||||
run: |
|
||||
bundle exec fastlane play_track_releases track:"$TRACK" out:"$RUNNER_TEMP/before.json"
|
||||
STAGED=$(jq -c '[.[] | select(.status == "inProgress")]' "$RUNNER_TEMP/before.json")
|
||||
if [[ $(jq length <<< "$STAGED") -ne 1 ]]; then
|
||||
echo "::error::Track '$TRACK' needs exactly one inProgress release; it holds: $(jq -c . "$RUNNER_TEMP/before.json")"
|
||||
exit 1
|
||||
fi
|
||||
VERSION_CODE=$(jq -r '.[0].version_codes | max' <<< "$STAGED")
|
||||
CURRENT=$(jq -r '.[0].user_fraction // empty' <<< "$STAGED")
|
||||
if [[ ! "$VERSION_CODE" =~ ^[0-9]+$ || ! "$CURRENT" =~ ^0?\.[0-9]+$ ]]; then
|
||||
echo "::error::The inProgress release on '$TRACK' has no usable version code or fraction: $STAGED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STATUS=""
|
||||
case "$ACTION" in
|
||||
rollout)
|
||||
if [[ ! "$FRACTION" =~ ^0?\.[0-9]+$ ]] || ! awk -v c="$CURRENT" -v n="$FRACTION" 'BEGIN { exit !(n > c && n < 1) }'; then
|
||||
echo "::error::fraction '$FRACTION' must be above the current $CURRENT and below 1; use complete to ship to everyone."
|
||||
exit 1
|
||||
fi
|
||||
ROLLOUT="$FRACTION"
|
||||
;;
|
||||
complete) ROLLOUT=1 ;;
|
||||
halt)
|
||||
ROLLOUT="$CURRENT"
|
||||
STATUS=halted
|
||||
;;
|
||||
esac
|
||||
echo "versionCode $VERSION_CODE on '$TRACK' at $CURRENT: $ACTION to $ROLLOUT${STATUS:+ ($STATUS)}"
|
||||
{
|
||||
echo "version_code=$VERSION_CODE"
|
||||
echo "current=$CURRENT"
|
||||
echo "rollout=$ROLLOUT"
|
||||
echo "status=$STATUS"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Every upload is skipped, so the edit changes only this release's status and fraction.
|
||||
- name: Change the rollout
|
||||
env:
|
||||
VERSION_CODE: ${{ steps.staged.outputs.version_code }}
|
||||
ROLLOUT: ${{ steps.staged.outputs.rollout }}
|
||||
STATUS: ${{ steps.staged.outputs.status }}
|
||||
run: |
|
||||
bundle exec fastlane supply \
|
||||
--track "$TRACK" \
|
||||
--version_code "$VERSION_CODE" \
|
||||
--rollout "$ROLLOUT" \
|
||||
${STATUS:+--release_status "$STATUS"} \
|
||||
--skip_upload_apk \
|
||||
--skip_upload_aab \
|
||||
--skip_upload_metadata \
|
||||
--skip_upload_changelogs \
|
||||
--skip_upload_images \
|
||||
--skip_upload_screenshots
|
||||
|
||||
# supply exits 0 even when its edit changed nothing. The script's verify counts only
|
||||
# completed and inProgress releases, so a halt is confirmed from a fresh read instead.
|
||||
- name: Verify the release on the track
|
||||
env:
|
||||
VERSION_CODE: ${{ steps.staged.outputs.version_code }}
|
||||
CURRENT: ${{ steps.staged.outputs.current }}
|
||||
ROLLOUT: ${{ steps.staged.outputs.rollout }}
|
||||
run: |
|
||||
PKG=$(grep '^APPLICATION_ID=' config.properties | cut -d'=' -f2)
|
||||
if [[ "$ACTION" != "halt" ]]; then
|
||||
bash scripts/play-track-preflight.sh \
|
||||
fastlane/play-store-credentials.json "$PKG" "$TRACK" "$VERSION_CODE" verify
|
||||
fi
|
||||
case "$ACTION" in
|
||||
rollout) WANT=inProgress ;;
|
||||
complete) WANT=completed ;;
|
||||
halt) WANT=halted ;;
|
||||
esac
|
||||
bundle exec fastlane play_track_releases track:"$TRACK" out:"$RUNNER_TEMP/after.json"
|
||||
AFTER=$(jq -c --argjson vc "$VERSION_CODE" '[.[] | select(.version_codes | index($vc))] | first // empty' "$RUNNER_TEMP/after.json")
|
||||
if [[ "$(jq -r '.status' <<< "${AFTER:-null}")" != "$WANT" ]]; then
|
||||
echo "::error::versionCode $VERSION_CODE on '$TRACK' is not $WANT after the change: ${AFTER:-absent}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ACTION" == "rollout" ]] && ! awk -v a="$(jq -r '.user_fraction' <<< "$AFTER")" -v w="$ROLLOUT" 'BEGIN { exit !(a == w) }'; then
|
||||
echo "::error::versionCode $VERSION_CODE on '$TRACK' is not at $ROLLOUT after the change: $AFTER"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "## Play rollout: ${TRACK}"
|
||||
echo
|
||||
echo "versionCode ${VERSION_CODE}: ${ACTION}, from ${CURRENT} to $(jq -r '.user_fraction // "all users"' <<< "$AFTER") (${WANT})."
|
||||
echo
|
||||
echo "A change Play could not send for review on its own waits under Publishing overview in the Play Console."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Clean up credentials
|
||||
if: always()
|
||||
run: rm -f fastlane/play-store-credentials.json
|
||||
@@ -1,5 +1,7 @@
|
||||
name: Post-Release Cleanup
|
||||
|
||||
# docs-release.yml dispatches this with confirm_deletion=true once a production tag's
|
||||
# /vX.Y.Z/ is on gh-pages. A manual dispatch is the retry path and defaults to a dry run.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -37,6 +39,18 @@ jobs:
|
||||
env:
|
||||
BASE_VERSION: ${{ github.event.inputs.base_version }}
|
||||
CONFIRM_DELETION: ${{ github.event.inputs.confirm_deletion }}
|
||||
# Keeps the vX.Y.Z-* names on stdin whose X.Y.Z is at or below BASE_VERSION. Anything
|
||||
# above it belongs to a later cycle, whose draft release promote.yml still needs.
|
||||
AT_OR_BELOW: |
|
||||
{
|
||||
v = $0; sub(/^v/, "", v); sub(/-.*/, "", v)
|
||||
split(v, a, "."); split(base, b, ".")
|
||||
for (i = 1; i <= 3; i++) {
|
||||
if (a[i] + 0 < b[i] + 0) { print; next }
|
||||
if (a[i] + 0 > b[i] + 0) next
|
||||
}
|
||||
print
|
||||
}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -65,12 +79,12 @@ jobs:
|
||||
# v2.7.14-internal.N draft built while working toward 2.8.0). Scoping this
|
||||
# sweep to "v${BASE_VERSION}-*" misses exactly those, leaving stale drafts
|
||||
# behind forever. Once base_version has shipped as a stable release, every
|
||||
# numbered internal/open/closed pre-release — regardless of which version
|
||||
# it was tagged under — is superseded, so match on the pre-release SHAPE
|
||||
# instead of a version prefix.
|
||||
# numbered internal/open/closed pre-release at or below it is superseded,
|
||||
# so match on the pre-release SHAPE capped by AT_OR_BELOW instead of a
|
||||
# version prefix.
|
||||
TAG_PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-(internal|open|closed)\.[0-9]+$'
|
||||
echo "Searching for pre-releases matching pattern '$TAG_PATTERN'."
|
||||
RELEASES_TO_DELETE=$(gh release list --json tagName,isPrerelease,isDraft --limit 1000 | jq -r --arg pattern "$TAG_PATTERN" '.[] | select((.isPrerelease == true or .isDraft == true) and .tagName != null and (.tagName | test($pattern))) | .tagName')
|
||||
echo "Searching for pre-releases matching pattern '$TAG_PATTERN' at or below $BASE_VERSION."
|
||||
RELEASES_TO_DELETE=$(gh release list --json tagName,isPrerelease,isDraft --limit 1000 | jq -r --arg pattern "$TAG_PATTERN" '.[] | select((.isPrerelease == true or .isDraft == true) and .tagName != null and (.tagName | test($pattern))) | .tagName' | awk -v base="$BASE_VERSION" "$AT_OR_BELOW")
|
||||
|
||||
if [ -z "$RELEASES_TO_DELETE" ]; then
|
||||
echo "No stale internal/open/closed pre-releases found."
|
||||
@@ -88,8 +102,9 @@ jobs:
|
||||
|
||||
# The docs site keeps a per-tag snapshot for every open/closed testing tag
|
||||
# in a version cycle (see docs-release.yml). Once vX.Y.Z ships, /vX.Y.Z/
|
||||
# supersedes them all, so reap them to stop gh-pages growing without bound.
|
||||
- name: Cleanup pre-release docs snapshots on gh-pages
|
||||
# supersedes them all, so reap them. Production copies older than the
|
||||
# release before vX.Y.Z go too, to keep gh-pages under the Pages size limit.
|
||||
- name: Cleanup superseded docs on gh-pages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DRY_RUN=true
|
||||
@@ -121,29 +136,51 @@ jobs:
|
||||
# dev cycle, including before the version-bump commit lands, so some may
|
||||
# still be named after the PRIOR release (e.g. v2.7.14-open.3 built while
|
||||
# working toward 2.8.0). Once /v${BASE_VERSION}/ is confirmed published
|
||||
# above, every numbered open/closed snapshot is superseded regardless of
|
||||
# which version it was tagged under — so match the snapshot-dir SHAPE
|
||||
# instead of a version prefix.
|
||||
# above, every numbered open/closed snapshot at or below it is superseded,
|
||||
# so match the snapshot-dir SHAPE capped by AT_OR_BELOW instead of a
|
||||
# version prefix.
|
||||
mapfile -t stale < <(
|
||||
find "$work" -maxdepth 1 -mindepth 1 -type d \
|
||||
-regextype posix-extended \
|
||||
-regex ".*/v[0-9]+\.[0-9]+\.[0-9]+-(open|closed)\.[0-9]+" -printf '%f\n' | sort
|
||||
-regex ".*/v[0-9]+\.[0-9]+\.[0-9]+-(open|closed)\.[0-9]+" -printf '%f\n' \
|
||||
| awk -v base="$BASE_VERSION" "$AT_OR_BELOW" | sort
|
||||
)
|
||||
|
||||
if [ ${#stale[@]} -eq 0 ]; then
|
||||
echo "No pre-release docs snapshots found."
|
||||
# Production copies strictly below base_version, oldest first. The newest of them
|
||||
# stays beside base_version; nothing above base_version is listed, so a backfill
|
||||
# dispatch for an old version never deletes newer docs.
|
||||
mapfile -t older < <(
|
||||
find "$work" -maxdepth 1 -mindepth 1 -type d \
|
||||
-regextype posix-extended \
|
||||
-regex ".*/v[0-9]+\.[0-9]+\.[0-9]+" -printf '%f\n' \
|
||||
| awk -v base="$BASE_VERSION" "$AT_OR_BELOW" \
|
||||
| grep -vxF "v${BASE_VERSION}" | sort -V || true
|
||||
)
|
||||
culled=()
|
||||
if [ ${#older[@]} -gt 1 ]; then
|
||||
culled=("${older[@]:0:${#older[@]}-1}")
|
||||
fi
|
||||
|
||||
if [ ${#stale[@]} -eq 0 ] && [ ${#culled[@]} -eq 0 ]; then
|
||||
echo "No superseded docs found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'Pre-release docs snapshots to reap:\n'
|
||||
printf ' %s\n' "${stale[@]}"
|
||||
if [ ${#stale[@]} -gt 0 ]; then
|
||||
printf 'Pre-release docs snapshots to reap:\n'
|
||||
printf ' %s\n' "${stale[@]}"
|
||||
fi
|
||||
if [ ${#culled[@]} -gt 0 ]; then
|
||||
printf 'Production docs older than %s to remove:\n' "${older[-1]}"
|
||||
printf ' %s\n' "${culled[@]}"
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "DRY RUN: the directories above would be removed from gh-pages."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for d in "${stale[@]}"; do
|
||||
for d in "${stale[@]}" "${culled[@]}"; do
|
||||
rm -rf "${work:?}/$d"
|
||||
done
|
||||
|
||||
@@ -159,9 +196,9 @@ jobs:
|
||||
fi
|
||||
git -c user.name='github-actions[bot]' \
|
||||
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
|
||||
commit -q -m "docs: reap pre-release snapshots for ${BASE_VERSION}"
|
||||
commit -q -m "docs: reap superseded docs for ${BASE_VERSION}"
|
||||
git push --quiet origin HEAD:gh-pages
|
||||
echo "Removed ${#stale[@]} pre-release docs snapshot(s) from gh-pages."
|
||||
echo "Removed ${#stale[@]} pre-release and ${#culled[@]} production docs directories from gh-pages."
|
||||
|
||||
- name: Cleanup dangling pre-release tags
|
||||
env:
|
||||
@@ -169,8 +206,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same rationale as the release-cleanup step above: match the
|
||||
# internal/open/closed pre-release tag SHAPE across all versions, not just
|
||||
# tags prefixed with this dispatch's base_version.
|
||||
# internal/open/closed pre-release tag SHAPE at or below base_version,
|
||||
# not just tags prefixed with it.
|
||||
TAG_PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+-(internal|open|closed)\.[0-9]+$'
|
||||
echo "Searching for any remaining remote pre-release tags matching pattern '$TAG_PATTERN'."
|
||||
|
||||
@@ -181,7 +218,7 @@ jobs:
|
||||
REMOTE_TAGS=$(git ls-remote --tags origin "refs/tags/v*" | awk '{print $2}' | sed 's|refs/tags/||')
|
||||
|
||||
# Some tags may have been deleted already by the previous 'release delete' step.
|
||||
TAGS_TO_DELETE=$(grep -E "$TAG_PATTERN" <<<"$REMOTE_TAGS" || true)
|
||||
TAGS_TO_DELETE=$(grep -E "$TAG_PATTERN" <<<"$REMOTE_TAGS" | awk -v base="$BASE_VERSION" "$AT_OR_BELOW" || true)
|
||||
|
||||
if [ -z "$TAGS_TO_DELETE" ]; then
|
||||
echo "No dangling pre-release tags found."
|
||||
|
||||
+434
-143
@@ -11,18 +11,10 @@ on:
|
||||
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
|
||||
@@ -31,29 +23,26 @@ on:
|
||||
description: 'The channel to promote from'
|
||||
required: true
|
||||
type: string
|
||||
version_code:
|
||||
description: 'The version code of the build being promoted'
|
||||
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
|
||||
HOMEBREW_TAP_TOKEN:
|
||||
required: false
|
||||
CROWDIN_GITHUB_TOKEN:
|
||||
required: false
|
||||
FLATHUB_TOKEN:
|
||||
required: false
|
||||
# Read only for presence, so the checklist says whether each store workflow will skip.
|
||||
WINGET_TOKEN:
|
||||
required: false
|
||||
MSSTORE_PRODUCT_ID:
|
||||
required: false
|
||||
|
||||
# Never cancel a promotion mid-flight: being killed between the Play edit
|
||||
# commit and the GitHub release/tag update leaves the two disagreeing. The
|
||||
@@ -65,59 +54,15 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
jobs:
|
||||
prepare-build-info:
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 10
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
env:
|
||||
VERSION_CODE_OFFSET: ${{ steps.get_version_code_offset.outputs.VERSION_CODE_OFFSET }}
|
||||
run: |
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
if ! [[ "$VERSION_CODE_OFFSET" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::VERSION_CODE_OFFSET from config.properties is not numeric: '$VERSION_CODE_OFFSET'"
|
||||
exit 1
|
||||
fi
|
||||
VERSION_CODE=$((COMMIT_COUNT + VERSION_CODE_OFFSET))
|
||||
echo "versionCode=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
|
||||
promote-release:
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 30
|
||||
needs: [ prepare-build-info ]
|
||||
outputs:
|
||||
already_on_track: ${{ steps.preflight.outputs.already_on_track }}
|
||||
screenshots: ${{ steps.screenshots.outputs.found }}
|
||||
listing: ${{ steps.listing.outcome }}
|
||||
env:
|
||||
FROM_TRACK: ${{ inputs.from_channel == 'closed' && 'NewAlpha' || (inputs.from_channel == 'open' && 'beta' || 'internal') }}
|
||||
TO_TRACK: ${{ inputs.channel == 'closed' && 'NewAlpha' || (inputs.channel == 'open' && 'beta' || 'production') }}
|
||||
@@ -126,16 +71,17 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.commit_sha || inputs.tag_name }}
|
||||
ref: ${{ inputs.tag_name }}
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
with:
|
||||
ruby-version: '4.0.7'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Decode Play Store credentials
|
||||
run: echo '${{ secrets.GOOGLE_PLAY_JSON_KEY }}' > fastlane/play-store-credentials.json
|
||||
env:
|
||||
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
|
||||
run: printf '%s\n' "$GOOGLE_PLAY_JSON_KEY" > fastlane/play-store-credentials.json
|
||||
|
||||
# A re-dispatched promotion whose versionCode is already live on the
|
||||
# target track must no-op: every redundant `supply` commit creates a new
|
||||
@@ -165,7 +111,7 @@ jobs:
|
||||
- name: Preflight — is this versionCode already on the target track?
|
||||
id: preflight
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
run: |
|
||||
PKG=$(grep '^APPLICATION_ID=' config.properties | cut -d'=' -f2)
|
||||
bash .workflow-ref/scripts/play-track-preflight.sh \
|
||||
@@ -174,10 +120,14 @@ jobs:
|
||||
# --version_code pins release selection. Without it supply picks the
|
||||
# source release by status (default "completed"), so a staged inProgress
|
||||
# rollout on the source track silently promotes the OLD release instead.
|
||||
# Changelogs are not skipped: with --track_promote_to, supply writes each
|
||||
# locale's changelogs/default.txt (rendered from metainfo.xml by
|
||||
# scripts/sync-play-changelog.py, translated by Crowdin) onto the promoted
|
||||
# release in the same edit, so the Play "What's new" is never empty.
|
||||
- name: Promote to next channel
|
||||
if: ${{ steps.preflight.outputs.already_on_track != 'true' }}
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
run: |
|
||||
bundle exec fastlane supply \
|
||||
--track "$FROM_TRACK" \
|
||||
@@ -186,7 +136,6 @@ jobs:
|
||||
--rollout "$USER_FRACTION" \
|
||||
--changes_not_sent_for_review true \
|
||||
--skip_upload_metadata \
|
||||
--skip_upload_changelogs \
|
||||
--skip_upload_images \
|
||||
--skip_upload_screenshots
|
||||
|
||||
@@ -195,52 +144,135 @@ jobs:
|
||||
- name: Verify versionCode landed on the target track
|
||||
if: ${{ steps.preflight.outputs.already_on_track != 'true' }}
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
run: |
|
||||
PKG=$(grep '^APPLICATION_ID=' config.properties | cut -d'=' -f2)
|
||||
bash .workflow-ref/scripts/play-track-preflight.sh \
|
||||
fastlane/play-store-credentials.json "$PKG" "$TO_TRACK" "$VERSION_CODE" verify
|
||||
|
||||
# The listing is the tag's text for every locale plus the google-flavor screenshots
|
||||
# the internal cut captured and attached to the release. Without the attachment
|
||||
# the committed set goes up instead, and the checklist says so.
|
||||
# An absent asset (a tag cut before the render job existed) falls back to
|
||||
# the committed set. A present asset that fails to download is an error,
|
||||
# and the listing step stays out rather than publish the wrong images.
|
||||
- name: Fetch the rendered store screenshots
|
||||
id: screenshots
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag_name }}
|
||||
run: |
|
||||
ASSET=$(gh release view "$TAG" --json assets --jq '.assets[].name | select(startswith("store-listing-screenshots-google-"))' | head -1)
|
||||
if [[ -z "$ASSET" ]]; then
|
||||
echo "::warning::No rendered screenshots attached to $TAG; the listing keeps the committed set."
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern "$ASSET" --dir "$RUNNER_TEMP/shots"
|
||||
unzip -qo "$RUNNER_TEMP/shots/$ASSET" -d fastlane/metadata/android/en-US/images
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# A dry run on the testing tracks, so a bad locale or image dimension fails
|
||||
# here and not on production, where the edit is committed. Held as "changes
|
||||
# not sent for review" either way; Play does not restart a release review
|
||||
# for it. Soft-fail: the track promotion above already happened.
|
||||
- name: Publish the Play listing
|
||||
id: listing
|
||||
if: ${{ steps.screenshots.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
VALIDATE_ONLY: ${{ inputs.channel != 'production' && 'true' || 'false' }}
|
||||
run: |
|
||||
python3 .workflow-ref/scripts/check-store-metadata.py
|
||||
bundle exec fastlane play_listing validate_only:"$VALIDATE_ONLY"
|
||||
|
||||
- name: Clean up credentials
|
||||
if: always()
|
||||
run: rm -f fastlane/play-store-credentials.json
|
||||
|
||||
update-github-release:
|
||||
runs-on: ubuntu-26.04-arm
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
needs: [ prepare-build-info, promote-release ]
|
||||
needs: [ promote-release ]
|
||||
# actions: write is scoped here — only this job's publish-workflow
|
||||
# dispatch needs it, and the other jobs must not get it. Job-level
|
||||
# permissions replace the workflow-level block, so the full set this
|
||||
# job uses is listed.
|
||||
# job uses is listed. Its PRs are opened with CROWDIN_GITHUB_TOKEN.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
actions: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.commit_sha || inputs.tag_name }}
|
||||
ref: ${{ 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 }}
|
||||
# Same reasoning as promote-release: scripts and the bot-pr action come from the
|
||||
# caller's commit, not the tag, so a tag cut before either landed still gets it.
|
||||
- name: Checkout release scripts from caller commit
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: .workflow-ref
|
||||
sparse-checkout: |
|
||||
scripts
|
||||
.github/actions/bot-pr
|
||||
|
||||
# A rerun finds the release already moved to the final tag, so that is looked up first.
|
||||
- name: Update GitHub Release with gh CLI
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag_name }}
|
||||
FINAL_TAG: ${{ inputs.final_tag }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
PRERELEASE: ${{ inputs.channel != 'production' }}
|
||||
run: |
|
||||
gh release edit ${{ inputs.tag_name }} \
|
||||
--tag ${{ inputs.final_tag }} \
|
||||
--title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \
|
||||
CURRENT=$(gh release view "$FINAL_TAG" --json tagName --jq .tagName 2>/dev/null || echo "$TAG")
|
||||
gh release edit "$CURRENT" \
|
||||
--tag "$FINAL_TAG" \
|
||||
--title "$FINAL_TAG ($VERSION_CODE)" \
|
||||
--draft=false \
|
||||
--prerelease=${{ inputs.channel != 'production' }}
|
||||
--prerelease="$PRERELEASE"
|
||||
|
||||
# The draft's notes were generated at the internal cut and cover only the
|
||||
# PRs since the previous published pre-release. A production release is
|
||||
# what users read, so regenerate its notes over the whole range since the
|
||||
# previous production tag, highlights first. The raw notes are kept for
|
||||
# the CHANGELOG.md stamp below. Soft-fail: the drafted notes stay in place
|
||||
# when this step cannot produce better ones.
|
||||
- name: Rewrite release notes for the full range
|
||||
id: notes
|
||||
if: ${{ inputs.channel == 'production' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.final_tag }}
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
PREV_PROD=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "^v${VERSION}$" | head -1)
|
||||
if [[ -z "$PREV_PROD" ]]; then
|
||||
echo "::warning::No previous production tag found; leaving the drafted release notes in place."
|
||||
exit 1
|
||||
fi
|
||||
echo "Previous production tag: $PREV_PROD"
|
||||
|
||||
gh api "repos/${REPO}/releases/generate-notes" \
|
||||
-f tag_name="$TAG" \
|
||||
-f target_commitish="$(git rev-parse HEAD)" \
|
||||
-f previous_tag_name="$PREV_PROD" \
|
||||
--jq '.body' > "$RUNNER_TEMP/notes-raw.md"
|
||||
|
||||
cp "$RUNNER_TEMP/notes-raw.md" "$RUNNER_TEMP/release-notes.md"
|
||||
python3 .workflow-ref/scripts/release-highlights.py \
|
||||
desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml \
|
||||
config.properties \
|
||||
"$RUNNER_TEMP/release-notes.md"
|
||||
gh release edit "$TAG" --notes-file "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
# The edit above is made with GITHUB_TOKEN, and GITHUB_TOKEN-caused
|
||||
# events never start workflow runs — winget-publish.yml's `released`
|
||||
@@ -248,6 +280,7 @@ jobs:
|
||||
# suppression, so dispatch it explicitly. Soft-fail: a winget hiccup
|
||||
# must not block the changelog stamp or Discord notification.
|
||||
- name: Dispatch winget publish
|
||||
id: winget
|
||||
if: ${{ inputs.channel == 'production' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
@@ -257,6 +290,7 @@ jobs:
|
||||
|
||||
# Same GITHUB_TOKEN-suppression story as the winget dispatch above.
|
||||
- name: Dispatch Microsoft Store publish
|
||||
id: msstore
|
||||
if: ${{ inputs.channel == 'production' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
@@ -264,35 +298,65 @@ jobs:
|
||||
TAG: ${{ inputs.final_tag }}
|
||||
run: gh workflow run msstore-publish.yml --ref main -f "tag=$TAG"
|
||||
|
||||
- name: Stamp CHANGELOG.md for release
|
||||
# Same suppression again: version-bump.yml opens the next patch line on main.
|
||||
- name: Dispatch version bump
|
||||
id: bump
|
||||
if: ${{ inputs.channel == 'production' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.final_tag }}
|
||||
run: gh workflow run version-bump.yml --ref main -f "tag=$TAG"
|
||||
|
||||
# docs-release.yml's tag trigger never fires either: the release edit
|
||||
# above creates the tag with GITHUB_TOKEN. Run it on the tag ref, which
|
||||
# publishes /vX.Y.Z/ (plus the root and /api/ for production) or the
|
||||
# /vX.Y.Z-open.N/ snapshot. The tag appears a moment after the undraft.
|
||||
- name: Dispatch docs release
|
||||
id: docs
|
||||
if: ${{ inputs.channel != 'internal' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.final_tag }}
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
gh api "repos/${REPO}/git/ref/tags/${TAG}" --silent && break
|
||||
sleep 10
|
||||
done
|
||||
gh workflow run docs-release.yml --ref "$TAG"
|
||||
|
||||
# The Obtainium table in README.md follows the channel releases; this refreshes it
|
||||
# now rather than at the next scheduled run.
|
||||
- name: Dispatch scheduled updates
|
||||
id: sched
|
||||
if: ${{ inputs.channel != 'internal' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run scheduled-updates.yml --ref main
|
||||
|
||||
# bot-pr resets the checked-out branch to its origin copy before branching the PR,
|
||||
# so the edits below are made on a local main, never on a branch origin lacks.
|
||||
- name: Stamp CHANGELOG.md for release
|
||||
id: stamp
|
||||
if: ${{ inputs.channel == 'production' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
run: |
|
||||
DATE=$(date -u +%Y-%m-%d)
|
||||
|
||||
# Checkout CHANGELOG.md from main (we're on a tag checkout)
|
||||
git fetch origin main
|
||||
git checkout -b "changelog/v${VERSION}" origin/main
|
||||
git checkout -B main origin/main
|
||||
|
||||
# Find the previous production tag for the full-range notes
|
||||
PREV_PROD=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "^v${VERSION}$" | head -1)
|
||||
|
||||
# Generate a single flat changelog for the entire release using GitHub API
|
||||
FLAT_NOTES=""
|
||||
if [ -n "$PREV_PROD" ]; then
|
||||
FLAT_NOTES=$(gh api "repos/${REPO}/releases/generate-notes" \
|
||||
-f tag_name="$TAG" \
|
||||
-f target_commitish="$(git rev-parse "$TAG")" \
|
||||
-f previous_tag_name="$PREV_PROD" \
|
||||
--jq '.body' 2>/dev/null || true)
|
||||
|
||||
# Strip boilerplate
|
||||
FLAT_NOTES=$(echo "$FLAT_NOTES" \
|
||||
| sed '/^<!-- .* -->/d' \
|
||||
# The full-range notes from the step above, minus the boilerplate.
|
||||
# The file is the record whether or not the release edit went
|
||||
# through; the checklist reports that edit. No file means generation
|
||||
# failed, and the section says so instead of stamping empty.
|
||||
FLAT_NOTES="*Release notes were not generated; run the Update Changelog workflow.*"
|
||||
if [ -s "$RUNNER_TEMP/notes-raw.md" ]; then
|
||||
FLAT_NOTES=$(sed '/^<!-- .* -->/d' "$RUNNER_TEMP/notes-raw.md" \
|
||||
| sed '/^## What'\''s Changed$/d' \
|
||||
| sed '/^\*\*Full Changelog\*\*/d' \
|
||||
| sed '/./,$!d' \
|
||||
@@ -331,33 +395,178 @@ jobs:
|
||||
f.write(new_content)
|
||||
" "$VERSION" "$DATE" "$FLAT_NOTES"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add CHANGELOG.md
|
||||
git diff --cached --quiet || {
|
||||
BRANCH="automation/changelog-v${VERSION}"
|
||||
git checkout -B "$BRANCH"
|
||||
git commit -m "docs: release CHANGELOG.md for v${VERSION}"
|
||||
git push origin "$BRANCH" --force
|
||||
- name: Open the CHANGELOG.md PR
|
||||
id: stamp_pr
|
||||
if: ${{ inputs.channel == 'production' }}
|
||||
uses: ./.workflow-ref/.github/actions/bot-pr
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
branch: automation/changelog-v${{ inputs.base_version }}
|
||||
title: 'docs: release CHANGELOG.md for v${{ inputs.base_version }}'
|
||||
body: 'Automated changelog stamp for production release v${{ inputs.base_version }}.'
|
||||
add-paths: CHANGELOG.md
|
||||
labels: |
|
||||
automation
|
||||
skip-changelog
|
||||
|
||||
gh pr create \
|
||||
--title "docs: release CHANGELOG.md for v${VERSION}" \
|
||||
--body "Automated changelog stamp for production release v${VERSION}." \
|
||||
--head "$BRANCH" \
|
||||
--base main \
|
||||
--label "automation" \
|
||||
--label "skip-changelog"
|
||||
# F-Droid and IzzyOnDroid read fastlane/ straight from git, so the committed set
|
||||
# follows what production shipped: the fdroid-flavor captures come back from the
|
||||
# release into a bot PR that merges itself, with the desktop set beside them. The
|
||||
# Flathub metainfo reads the release assets directly and needs nothing here.
|
||||
# Nothing is opened when the files already match.
|
||||
- name: Stage the store screenshots from the release
|
||||
id: shots
|
||||
if: ${{ inputs.channel == 'production' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.final_tag }}
|
||||
run: |
|
||||
ASSET=$(gh release view "$TAG" --json assets --jq '.assets[].name | select(startswith("store-listing-screenshots-fdroid-"))' | head -1)
|
||||
if [[ -z "$ASSET" ]]; then
|
||||
echo "::warning::No rendered screenshots attached to $TAG; the committed set stays."
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern "$ASSET" --pattern 'meshtastic-desktop-*.png' --dir "$RUNNER_TEMP/shots"
|
||||
git fetch origin main
|
||||
git checkout -B main origin/main
|
||||
unzip -qo "$RUNNER_TEMP/shots/$ASSET" -d fastlane/metadata/android/en-US/images
|
||||
cp "$RUNNER_TEMP"/shots/meshtastic-desktop-*.png desktopApp/packaging/linux/screenshots/
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Post required commit status so the PR isn't blocked
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
gh api "repos/${{ github.repository }}/statuses/${COMMIT_SHA}" \
|
||||
-f state="success" \
|
||||
-f context="Check Workflow Status" \
|
||||
-f description="Skipped — changelog-only PR"
|
||||
- name: Refresh the committed store screenshots
|
||||
id: shots_pr
|
||||
if: ${{ steps.shots.outputs.found == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: ./.workflow-ref/.github/actions/bot-pr
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
branch: automation/store-screenshots-v${{ inputs.base_version }}
|
||||
title: 'chore(store): refresh the committed screenshots from v${{ inputs.base_version }}'
|
||||
body: 'The screenshots rendered for the v${{ inputs.base_version }} release, so the fastlane tree F-Droid reads matches what shipped.'
|
||||
add-paths: |
|
||||
fastlane/metadata/android/en-US/images
|
||||
desktopApp/packaging/linux/screenshots
|
||||
labels: |
|
||||
automation
|
||||
skip-changelog
|
||||
|
||||
# One place to read what this promotion did, what it dispatched, and what
|
||||
# is still done by hand. The hand-done list stays out of the Discord post,
|
||||
# which is an announcement.
|
||||
- name: Release checklist
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
TAG: ${{ inputs.final_tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
ALREADY_ON_TRACK: ${{ needs.promote-release.outputs.already_on_track }}
|
||||
SCREENSHOTS: ${{ needs.promote-release.outputs.screenshots }}
|
||||
LISTING: ${{ needs.promote-release.outputs.listing }}
|
||||
SHOTS: ${{ steps.shots.outcome }}
|
||||
SHOTS_FOUND: ${{ steps.shots.outputs.found }}
|
||||
SHOTS_PR: ${{ steps.shots_pr.outputs.url }}
|
||||
SHOTS_PR_OUTCOME: ${{ steps.shots_pr.outcome }}
|
||||
RELEASE: ${{ steps.release.outcome }}
|
||||
NOTES: ${{ steps.notes.outcome }}
|
||||
DOCS: ${{ steps.docs.outcome }}
|
||||
SCHED: ${{ steps.sched.outcome }}
|
||||
WINGET: ${{ steps.winget.outcome }}
|
||||
MSSTORE: ${{ steps.msstore.outcome }}
|
||||
BUMP: ${{ steps.bump.outcome }}
|
||||
STAMP: ${{ steps.stamp.outcome }}
|
||||
STAMP_PR: ${{ steps.stamp_pr.outputs.url }}
|
||||
STAMP_PR_OUTCOME: ${{ steps.stamp_pr.outcome }}
|
||||
HAS_FLATHUB_TOKEN: ${{ secrets.FLATHUB_TOKEN != '' }}
|
||||
HAS_WINGET_TOKEN: ${{ secrets.WINGET_TOKEN != '' }}
|
||||
HAS_MSSTORE_PRODUCT_ID: ${{ secrets.MSSTORE_PRODUCT_ID != '' }}
|
||||
HAS_HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }}
|
||||
run: |
|
||||
row() { printf '| %s | %s |\n' "$1" "$2"; }
|
||||
# A store workflow skips inside itself when its secret is unset, so a successful
|
||||
# dispatch alone does not mean anything was submitted.
|
||||
store_row() {
|
||||
if [[ "$2" != "success" ]]; then
|
||||
row "$1" "$2"
|
||||
elif [[ "$3" != "true" ]]; then
|
||||
row "$1" "will skip: $4 unset"
|
||||
else
|
||||
row "$1" "dispatched"
|
||||
fi
|
||||
}
|
||||
{
|
||||
echo "## Release checklist: ${TAG} (${CHANNEL})"
|
||||
echo
|
||||
echo "| Step | Result |"
|
||||
echo "|---|---|"
|
||||
if [[ "$ALREADY_ON_TRACK" == "true" ]]; then
|
||||
row "Play promotion" "no-op, versionCode already on the track"
|
||||
else
|
||||
row "Play promotion" "done, changelogs uploaded"
|
||||
fi
|
||||
row "GitHub release" "$RELEASE - https://github.com/${REPO}/releases/tag/${TAG}"
|
||||
if [[ "$SCREENSHOTS" == "true" ]]; then
|
||||
row "Store screenshots" "rendered set from the release"
|
||||
else
|
||||
row "Store screenshots" "none attached, committed set used"
|
||||
fi
|
||||
if [[ "$CHANNEL" == "production" ]]; then
|
||||
row "Play listing published" "$LISTING"
|
||||
else
|
||||
row "Play listing validated (dry run)" "$LISTING"
|
||||
fi
|
||||
row "Docs Release dispatched" "$DOCS"
|
||||
row "Scheduled Updates dispatched (Obtainium table)" "$SCHED"
|
||||
if [[ "$CHANNEL" == "production" ]]; then
|
||||
if [[ -n "$SHOTS_PR" ]]; then
|
||||
row "Committed screenshots refresh PR" "$SHOTS_PR"
|
||||
elif [[ "$SHOTS" != "success" ]]; then
|
||||
row "Committed screenshots refresh PR" "$SHOTS"
|
||||
elif [[ "$SHOTS_FOUND" != "true" ]]; then
|
||||
row "Committed screenshots refresh PR" "none needed (no rendered set attached)"
|
||||
elif [[ "$SHOTS_PR_OUTCOME" == "success" ]]; then
|
||||
row "Committed screenshots refresh PR" "none needed (committed set matches)"
|
||||
else
|
||||
row "Committed screenshots refresh PR" "$SHOTS_PR_OUTCOME"
|
||||
fi
|
||||
row "Release notes rewritten (full range)" "$NOTES"
|
||||
if [[ -n "$STAMP_PR" ]]; then
|
||||
row "CHANGELOG.md stamp PR" "$STAMP_PR"
|
||||
elif [[ "$STAMP" == "success" && "$STAMP_PR_OUTCOME" == "success" ]]; then
|
||||
row "CHANGELOG.md stamp PR" "none needed (already stamped)"
|
||||
else
|
||||
row "CHANGELOG.md stamp PR" "stamp $STAMP, PR ${STAMP_PR_OUTCOME:-skipped}"
|
||||
fi
|
||||
store_row "winget" "$WINGET" "$HAS_WINGET_TOKEN" WINGET_TOKEN
|
||||
store_row "Microsoft Store" "$MSSTORE" "$HAS_MSSTORE_PRODUCT_ID" MSSTORE_PRODUCT_ID
|
||||
row "Version bump dispatched" "$BUMP"
|
||||
row "Post-Release Cleanup" "dispatched by Docs Release once /${TAG}/ is published"
|
||||
if [[ "$HAS_HOMEBREW_TAP_TOKEN" == "true" ]]; then
|
||||
row "Homebrew cask PR" "opened by the update-homebrew-cask job after this one"
|
||||
else
|
||||
row "Homebrew cask PR" "will skip: HOMEBREW_TAP_TOKEN unset"
|
||||
fi
|
||||
if [[ "$HAS_FLATHUB_TOKEN" == "true" ]]; then
|
||||
row "Flathub update PR" "opened by the update-flathub job after this one"
|
||||
else
|
||||
row "Flathub update PR" "will skip: FLATHUB_TOKEN unset"
|
||||
fi
|
||||
echo
|
||||
echo "Still by hand:"
|
||||
echo "- Play: the production rollout is staged; widen, complete or halt it with the Play Rollout workflow."
|
||||
if [[ "$HAS_FLATHUB_TOKEN" == "true" ]]; then
|
||||
echo "- Flathub: merge the update-flathub PR once its test build passes."
|
||||
else
|
||||
echo "- Flathub: bump flathub/org.meshtastic.MeshtasticDesktop (tag, commit, gradle zip and sha256, flatpak-sources.json)."
|
||||
fi
|
||||
echo "- Release notes: replace the placeholder <description> the version bump PR wrote for the next line."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Announces once the release is published, whatever happened to the steps after it.
|
||||
- name: Notify Discord
|
||||
if: ${{ inputs.channel != 'internal' }}
|
||||
if: ${{ always() && steps.release.outcome == 'success' && inputs.channel != 'internal' }}
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_ANDROID }}
|
||||
VERSION: ${{ inputs.final_tag }}
|
||||
@@ -408,14 +617,14 @@ jobs:
|
||||
# promoted to homebrew/cask, replace the tap PR with `brew bump-cask-pr`.
|
||||
update-homebrew-cask:
|
||||
if: ${{ inputs.channel == 'production' }}
|
||||
runs-on: ubuntu-26.04-arm
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 15
|
||||
needs: [ update-github-release ]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.commit_sha || inputs.tag_name }}
|
||||
ref: ${{ inputs.tag_name }}
|
||||
|
||||
- name: Render cask and open PR against meshtastic/homebrew-tap
|
||||
env:
|
||||
@@ -423,12 +632,14 @@ jobs:
|
||||
TAG: ${{ inputs.final_tag }}
|
||||
run: |
|
||||
if [[ -z "$TAP_TOKEN" ]]; then
|
||||
echo "HOMEBREW_TAP_TOKEN not set; skipping Homebrew cask update."
|
||||
echo "::warning::HOMEBREW_TAP_TOKEN not set; skipping Homebrew cask update."
|
||||
echo "- Homebrew cask: skipped, HOMEBREW_TAP_TOKEN not set" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
# Tags cut before the template landed check out without it — skip, don't fail.
|
||||
if [[ ! -f .github/homebrew/meshtastic-desktop.rb ]]; then
|
||||
echo "Cask template not present at this tag; skipping Homebrew cask update."
|
||||
echo "::warning::Cask template not present at this tag; skipping Homebrew cask update."
|
||||
echo "- Homebrew cask: skipped, no template at this tag" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -459,8 +670,88 @@ jobs:
|
||||
fi
|
||||
git commit -m "meshtastic-desktop ${VERSION}"
|
||||
git push -fu origin "$BRANCH"
|
||||
GH_TOKEN="$TAP_TOKEN" gh pr create --repo meshtastic/homebrew-tap \
|
||||
PR_URL=$(GH_TOKEN="$TAP_TOKEN" gh pr create --repo meshtastic/homebrew-tap \
|
||||
--head "$BRANCH" --base main \
|
||||
--title "meshtastic-desktop ${VERSION}" \
|
||||
--body "Automated cask bump for the ${TAG} production release of [Meshtastic-Android](https://github.com/${{ github.repository }}/releases/tag/${TAG})." \
|
||||
|| echo "PR already exists for $BRANCH; branch updated."
|
||||
|| echo "PR already exists for $BRANCH; branch updated.")
|
||||
echo "- Homebrew cask: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The same for flathub/org.meshtastic.MeshtasticDesktop: the tag and its commit, the Gradle
|
||||
# zip that tag's wrapper pins, and the release's flatpak-sources.json. The JBR, runtime and
|
||||
# patches stay as they are; Flathub's test build on the PR checks them against the tag.
|
||||
update-flathub:
|
||||
if: ${{ inputs.channel == 'production' }}
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 15
|
||||
needs: [ update-github-release ]
|
||||
steps:
|
||||
# From the caller's commit, like the scripts in the jobs above.
|
||||
- name: Checkout the manifest bump script from caller commit
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
sparse-checkout: scripts/verify-flatpak
|
||||
|
||||
- name: Update the manifest and open PR against flathub/org.meshtastic.MeshtasticDesktop
|
||||
env:
|
||||
FLATHUB_TOKEN: ${{ secrets.FLATHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.final_tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [[ -z "$FLATHUB_TOKEN" ]]; then
|
||||
echo "::notice::FLATHUB_TOKEN not set; skipping the Flathub update."
|
||||
echo "- Flathub: skipped, FLATHUB_TOKEN not set" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
VERSION=${TAG#v}
|
||||
|
||||
# The tag appears a moment after the undraft.
|
||||
TYPE="" COMMIT=""
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
if OBJ=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.type + " " + .object.sha' 2>/dev/null); then
|
||||
read -r TYPE COMMIT <<< "$OBJ"
|
||||
break
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
if [[ -z "$COMMIT" ]]; then
|
||||
echo "::error::Tag $TAG not found."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$TYPE" == "tag" ]]; then
|
||||
COMMIT=$(gh api "repos/${REPO}/git/tags/${COMMIT}" --jq .object.sha)
|
||||
fi
|
||||
|
||||
gh api "repos/${REPO}/contents/gradle/wrapper/gradle-wrapper.properties?ref=${COMMIT}" \
|
||||
-H 'Accept: application/vnd.github.raw' > "$RUNNER_TEMP/gradle-wrapper.properties"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern flatpak-sources.json --dir "$RUNNER_TEMP"
|
||||
jq empty "$RUNNER_TEMP/flatpak-sources.json"
|
||||
|
||||
FLATHUB="$RUNNER_TEMP/flathub"
|
||||
git clone "https://x-access-token:${FLATHUB_TOKEN}@github.com/flathub/org.meshtastic.MeshtasticDesktop.git" "$FLATHUB"
|
||||
BASE=$(git -C "$FLATHUB" rev-parse --abbrev-ref HEAD)
|
||||
BRANCH="update-${VERSION}"
|
||||
git -C "$FLATHUB" checkout -b "$BRANCH"
|
||||
python3 scripts/verify-flatpak/bump-flathub-manifest.py \
|
||||
"$FLATHUB/org.meshtastic.MeshtasticDesktop.yaml" "$TAG" "$COMMIT" "$RUNNER_TEMP/gradle-wrapper.properties"
|
||||
cp "$RUNNER_TEMP/flatpak-sources.json" "$FLATHUB/flatpak-sources.json"
|
||||
|
||||
cd "$FLATHUB"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add org.meshtastic.MeshtasticDesktop.yaml flatpak-sources.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "Flathub manifest already at ${TAG}."
|
||||
echo "- Flathub: already at ${TAG}" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update to ${TAG}"
|
||||
git push -fu origin "$BRANCH"
|
||||
PR_URL=$(GH_TOKEN="$FLATHUB_TOKEN" gh pr create --repo flathub/org.meshtastic.MeshtasticDesktop \
|
||||
--head "$BRANCH" --base "$BASE" \
|
||||
--title "Update to ${TAG}" \
|
||||
--body "$(printf 'Update Meshtastic Desktop to version %s\nhttps://github.com/%s/releases/tag/%s\n' "$TAG" "$REPO" "$TAG")" \
|
||||
|| echo "PR already exists for $BRANCH; branch updated.")
|
||||
echo "- Flathub: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Protobufs Bump
|
||||
|
||||
# A PR that moves the org.meshtastic:protobufs pin gets one sticky comment saying what the bump carries: the
|
||||
# upstream PRs merged in between, the .proto delta, and the settings strings android will show differently once
|
||||
# values/schema_strings.xml is regenerated. The regeneration itself is not done here: the scheduled-updates run
|
||||
# this merge triggers on main syncs the file from whatever pin main has and carries it, with the Crowdin upload, in
|
||||
# its own PR. Nothing is pushed to this branch, so Renovate keeps rebasing and automerging it.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- gradle/libs.versions.toml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: protobufs-bump-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
summarise:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
# A fork PR gets a read-only token, so the comment could not be posted anyway.
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
steps:
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
filter: 'blob:none'
|
||||
|
||||
- name: Read the protobufs pin on both sides
|
||||
id: pin
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
line='meshtastic-protobufs = '
|
||||
old=$(git show "origin/$BASE_REF:gradle/libs.versions.toml" | sed -n "s/^${line}\"\(.*\)\"$/\1/p")
|
||||
new=$(sed -n "s/^${line}\"\(.*\)\"$/\1/p" gradle/libs.versions.toml)
|
||||
echo "old=$old" >> "$GITHUB_OUTPUT"
|
||||
echo "new=$new" >> "$GITHUB_OUTPUT"
|
||||
if [ "$old" = "$new" ]; then
|
||||
echo "protobufs pin unchanged ($new); nothing to do"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "protobufs $old -> $new"
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Gradle Setup
|
||||
if: steps.pin.outputs.changed == 'true'
|
||||
uses: ./.github/actions/gradle-setup
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
job_summary_pr_comment: 'never'
|
||||
cache_read_only: 'true'
|
||||
cache_robolectric: 'false'
|
||||
|
||||
# The sync is run to see what it would change; the result is not committed here.
|
||||
- name: Preview the schema strings at the new pin
|
||||
if: steps.pin.outputs.changed == 'true'
|
||||
run: |
|
||||
cp core/resources/src/commonMain/composeResources/values/schema_strings.xml /tmp/schema_strings.before.xml
|
||||
./gradlew :schema-strings:sync -Pci=true
|
||||
cp core/resources/src/commonMain/composeResources/values/schema_strings.xml /tmp/schema_strings.after.xml
|
||||
git checkout -- core/resources/src/commonMain/composeResources/values/schema_strings.xml
|
||||
|
||||
- name: Clone protobufs at both commits
|
||||
if: steps.pin.outputs.changed == 'true'
|
||||
run: git clone --filter=blob:none --quiet https://github.com/meshtastic/protobufs.git /tmp/protobufs
|
||||
|
||||
- name: Write the summary
|
||||
if: steps.pin.outputs.changed == 'true'
|
||||
env:
|
||||
OLD_PIN: ${{ steps.pin.outputs.old }}
|
||||
NEW_PIN: ${{ steps.pin.outputs.new }}
|
||||
run: |
|
||||
python3 scripts/protobufs-bump-summary.py "$OLD_PIN" "$NEW_PIN" \
|
||||
--protobufs /tmp/protobufs \
|
||||
--before /tmp/schema_strings.before.xml \
|
||||
--after /tmp/schema_strings.after.xml \
|
||||
> /tmp/summary.md
|
||||
{
|
||||
echo
|
||||
echo "_values/schema_strings.xml is regenerated by the scheduled-updates run this merge triggers on main._"
|
||||
} >> /tmp/summary.md
|
||||
cat /tmp/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Post or refresh the sticky comment
|
||||
if: steps.pin.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
existing=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \
|
||||
--jq '.[] | select(.body | startswith("<!-- protobufs-bump-summary -->")) | .id' | head -n1)
|
||||
if [ -n "$existing" ]; then
|
||||
gh api --method PATCH "repos/$REPO/issues/comments/$existing" -F body=@/tmp/summary.md > /dev/null
|
||||
else
|
||||
gh pr comment "$PR" --body-file /tmp/summary.md
|
||||
fi
|
||||
@@ -5,9 +5,10 @@ on:
|
||||
# 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
|
||||
# No concurrency group. Renovate labels its PR right after opening it, so a cancelling group left a cancelled
|
||||
# `labeler` check run on the head commit, and Renovate reads any cancelled check run as a pending branch it never
|
||||
# automerges. A non-cancelling group still cancels a queued run when a third event arrives. The job is idempotent
|
||||
# and seconds on ubuntu-slim, so every run can simply finish.
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
@@ -117,7 +118,7 @@ jobs:
|
||||
const author = context.payload.pull_request.user.login;
|
||||
const headRef = context.payload.pull_request.head.ref;
|
||||
const skipAuthors = ['renovate[bot]', 'github-actions[bot]', 'dependabot[bot]'];
|
||||
const skipRefs = ['scheduled-updates', 'l10n_main'];
|
||||
const skipRefs = ['scheduled-updates'];
|
||||
if (!skipAuthors.includes(author) && !skipRefs.includes(headRef)) {
|
||||
const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor', 'desktop', 'chore', 'ci', 'build', 'testing', 'documentation'];
|
||||
const effectiveLabels = new Set([
|
||||
|
||||
@@ -17,7 +17,9 @@ jobs:
|
||||
# (folded into this job rather than run standalone: runner-pool slots, not
|
||||
# compute, are the scarce resource during queue bursts).
|
||||
check-changes:
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
|
||||
# scheduled-baseline changes only the googleRelease baseline profile and READMEs, which
|
||||
# no PR job builds; the merge queue still runs everything.
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'scheduled-baseline' )
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
@@ -61,8 +63,10 @@ jobs:
|
||||
desktop:
|
||||
- 'desktopApp/**'
|
||||
- 'scripts/build-appimage.sh'
|
||||
# Shared build machinery that shapes the packaged output.
|
||||
# Shared build machinery that shapes the packaged output. config/ holds the
|
||||
# ProGuard rules desktopApp's release jars read and the license texts it bundles.
|
||||
- 'build-logic/**'
|
||||
- 'config/**'
|
||||
- 'gradle/wrapper/**'
|
||||
- 'gradle/libs.versions.toml'
|
||||
# Root inputs the packaging tasks actually read: config.properties supplies the
|
||||
@@ -88,7 +92,8 @@ jobs:
|
||||
- 'feature/**'
|
||||
- 'screenshot-tests/**'
|
||||
- 'docs-screenshots/**'
|
||||
- 'marketing-screenshots/**'
|
||||
- 'store-screenshots/**'
|
||||
- 'schema-strings/**'
|
||||
# Shared build infrastructure
|
||||
- 'build-logic/**'
|
||||
- 'config/**'
|
||||
@@ -103,182 +108,20 @@ jobs:
|
||||
- 'settings.gradle.kts'
|
||||
- 'test.gradle.kts'
|
||||
- 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)
|
||||
}
|
||||
|
||||
# Filter roots that are intentionally not Gradle module roots
|
||||
# (CI/workflow implementation + shared build infrastructure).
|
||||
allowed_infra_roots = {'.github', 'build-logic', 'config', 'gradle'}
|
||||
|
||||
missing = sorted(expected_roots - filter_paths)
|
||||
unexpected = sorted(filter_paths - expected_roots - allowed_infra_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
|
||||
# Drift guard: ALL_MODULES_FULL in RootConventionPlugin.kt is a hand-maintained
|
||||
# copy of settings.gradle.kts (subprojects {} iteration is incompatible with
|
||||
# Isolated Projects). It has drifted before: :feature:discovery, :feature:docs
|
||||
# and :feature:map-maplibre were never added, so they were silently absent from
|
||||
# Dokka aggregation, Kover aggregation and kmpSmokeCompile.
|
||||
run: python3 scripts/check-changes-filter.py
|
||||
- name: Verify the root module list matches settings.gradle.kts
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
settings = Path('settings.gradle.kts').read_text()
|
||||
plugin = Path(
|
||||
'build-logic/convention/src/main/kotlin/RootConventionPlugin.kt'
|
||||
).read_text()
|
||||
|
||||
include = settings[settings.index('include('):]
|
||||
modules = set(re.findall(r'"(:[^"]+)"', include[: include.index('\n)')]))
|
||||
|
||||
# ALL_MODULES_FULL appears twice (declaration and use), so bound the slice to
|
||||
# the declaration's own closing paren rather than searching for the name.
|
||||
decl = plugin[plugin.index('ALL_MODULES_FULL ='):]
|
||||
listed = set(re.findall(r'"(:[^"]+)"', decl[: decl.index('\n )')]))
|
||||
|
||||
# Test harnesses and generators, deliberately kept out of root aggregation
|
||||
# (PR #6412). Excluded here so the guard does not force them back in.
|
||||
exempt = {
|
||||
':baselineprofile',
|
||||
':core:konsist',
|
||||
':docs-screenshots',
|
||||
':marketing-screenshots',
|
||||
':screenshot-tests',
|
||||
}
|
||||
|
||||
missing = sorted(modules - listed - exempt)
|
||||
extra = sorted(listed - modules)
|
||||
# The exemption is bidirectional: an exempt module must also stay OUT of the
|
||||
# list. Without this, adding one back would pass silently and quietly undo
|
||||
# #6412 by pulling a test harness into Dokka, Kover and kmpSmokeCompile.
|
||||
readded = sorted(listed & exempt)
|
||||
|
||||
problems = []
|
||||
for m in missing:
|
||||
problems.append(
|
||||
f'{m} is in settings.gradle.kts but not ALL_MODULES_FULL -- it is absent '
|
||||
'from Dokka/Kover aggregation and kmpSmokeCompile'
|
||||
)
|
||||
for m in extra:
|
||||
problems.append(f'{m} is in ALL_MODULES_FULL but no longer in settings.gradle.kts')
|
||||
for m in readded:
|
||||
problems.append(
|
||||
f'{m} is exempt from root aggregation (#6412) but present in '
|
||||
'ALL_MODULES_FULL -- remove it, or drop it from the exempt set here'
|
||||
)
|
||||
|
||||
if problems:
|
||||
print('Root module list drift detected:')
|
||||
for p in problems:
|
||||
print(' -', p)
|
||||
raise SystemExit(1)
|
||||
|
||||
print(f'{len(listed)} modules verified against settings.gradle.kts '
|
||||
f'({len(exempt)} exempt).')
|
||||
PY
|
||||
# Drift guard: the shard task lists in reusable-check.yml are
|
||||
# hand-maintained and have silently dropped modules before (discovery,
|
||||
# docs, wifi-provision, car, datastore, konsist had tests that never ran
|
||||
# in CI). Every module in settings.gradle.kts must appear in the shard
|
||||
# matrix or be explicitly exempted — and an exempt module that gains test
|
||||
# sources fails the guard until it is wired into a shard.
|
||||
run: python3 scripts/check-module-list.py
|
||||
- name: Verify every module with tests is wired into a CI test shard
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import re
|
||||
from pathlib import Path
|
||||
run: python3 scripts/check-test-shards.py
|
||||
|
||||
settings = Path('settings.gradle.kts').read_text()
|
||||
check = Path('.github/workflows/reusable-check.yml').read_text()
|
||||
|
||||
modules = set(re.findall(r'"(:[^"]+)"', settings))
|
||||
|
||||
# Modules whose tests run in a dedicated job or only on-device --
|
||||
# exempt unconditionally.
|
||||
covered_elsewhere = {
|
||||
':screenshot-tests', # dedicated screenshot-check job
|
||||
':docs-screenshots', # doc-screenshot generation (screenshot tooling)
|
||||
':baselineprofile', # benchmark module, instrumented-only
|
||||
}
|
||||
# Modules with no unit-test sources yet. One of these gaining test
|
||||
# sources fails the guard: move it into a shard in reusable-check.yml
|
||||
# and remove it from this list.
|
||||
no_tests_yet = {
|
||||
':core:di',
|
||||
':marketing-screenshots', # store-listing screenshot generator, a JVM program
|
||||
':core:nfc',
|
||||
':core:resources',
|
||||
}
|
||||
|
||||
shards = check.split('# ── Sharded Unit Tests')[1].split('# ── Android Build')[0]
|
||||
|
||||
def has_test_sources(module):
|
||||
root = Path(module.lstrip(':').replace(':', '/'))
|
||||
return any(
|
||||
f.suffix == '.kt'
|
||||
for d in root.glob('src/*')
|
||||
if 'test' in d.name.lower()
|
||||
for f in d.rglob('*.kt')
|
||||
)
|
||||
|
||||
problems = []
|
||||
for m in sorted(modules):
|
||||
if m in covered_elsewhere:
|
||||
continue
|
||||
if m in no_tests_yet:
|
||||
if has_test_sources(m):
|
||||
problems.append(f'{m} is exempt as test-less but has test sources -- wire it into a shard')
|
||||
# Require an actual test task (allTests / test / test<Variant>UnitTest),
|
||||
# not just any reference -- a lone kover entry must not satisfy this.
|
||||
elif not re.search(rf'{re.escape(m)}:(allTests|test)', shards):
|
||||
problems.append(f'{m} has no test task in any reusable-check.yml test shard')
|
||||
|
||||
if problems:
|
||||
print('CI shard coverage drift detected:')
|
||||
for p in problems:
|
||||
print(' -', p)
|
||||
raise SystemExit(1)
|
||||
exempt = covered_elsewhere | no_tests_yet
|
||||
print(f'{len(modules) - len(modules & exempt)} modules verified against the shard matrix.')
|
||||
PY
|
||||
|
||||
# 1c. STORE METADATA: Enforce store-listing length limits (e.g. the F-Droid /
|
||||
# Play 80-char short_description). These files are mirrored from Crowdin, so
|
||||
# this guard intentionally runs on the translation-sync PRs too (no
|
||||
# scheduled-updates / l10n_main skip) -- that is where overlength translations
|
||||
# 1c. REPO CHECKS: actionlint, shellcheck, the script self-tests, and the store-listing,
|
||||
# AppStream and generated-file checks. Store listings are mirrored from Crowdin, so
|
||||
# this job intentionally runs on the translation-sync PRs too (no
|
||||
# scheduled-updates skip) -- that is where overlength translations
|
||||
# land. It is a standalone lightweight job, decoupled from the Gradle build so
|
||||
# a one-line translation fix never triggers a full assemble/test cycle.
|
||||
check-metadata:
|
||||
name: Check Store Metadata
|
||||
name: Check Workflows, Scripts & Metadata
|
||||
if: github.repository == 'meshtastic/Meshtastic-Android'
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 5
|
||||
@@ -286,12 +129,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Workflow linting (actionlint + its shellcheck integration over every run: block).
|
||||
# Version-pinned; the runner image ships shellcheck. ~1s over the whole tree.
|
||||
# actionlint plus its shellcheck pass over every run: block. The image bundles
|
||||
# shellcheck and reads .github/actionlint.yaml from the mounted workspace.
|
||||
- name: Lint GitHub workflows (actionlint)
|
||||
run: |
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.12/scripts/download-actionlint.bash) 1.7.12 /tmp
|
||||
/tmp/actionlint -color
|
||||
uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667
|
||||
with:
|
||||
args: -color
|
||||
- name: Lint repo shell scripts (shellcheck)
|
||||
# -x so sourced libraries are followed into rather than reported as SC1091, and
|
||||
# find rather than a glob: scripts/*.sh missed scripts/lib/, scripts/docs/ and
|
||||
@@ -351,6 +194,12 @@ jobs:
|
||||
echo "::error file=$METAINFO::Missing <release version=\"$VERSION\"> entry. Add it alongside the VERSION_NAME_BASE bump."
|
||||
exit 1
|
||||
fi
|
||||
# Flathub wants screenshot links from a tag or a commit, never a branch. Ours
|
||||
# are the release assets the internal cut attaches, so they name the version.
|
||||
if grep '<image>' "$METAINFO" | grep -qvF "/releases/download/v$VERSION/"; then
|
||||
echo "::error file=$METAINFO::Every <image> URL must point at the v$VERSION release assets (releases/download/v$VERSION/...). Update them alongside the VERSION_NAME_BASE bump."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Flathub runs this exact command (flatpak-builder-lint checks/metainfo.py) and turns any
|
||||
# non-zero exit into appstream-failed-validation at publish time. Nothing here ran it, so a
|
||||
@@ -393,8 +242,8 @@ jobs:
|
||||
|
||||
# 3. WORKFLOW STATUS: Ensures required checks are satisfied
|
||||
# Pure gate job: no checkout, no toolchain, just reads `needs` results. It is the required
|
||||
# check, so it runs on the label the rest of the workflow already gets slots on, not on
|
||||
# ubuntu-slim's separate pool: an aggregator queued behind slim blocks a finished build.
|
||||
# check, so it runs on a hosted Ubuntu label that shares the org's pool with the build jobs,
|
||||
# not on ubuntu-slim's separate pool: an aggregator queued behind slim blocks a finished build.
|
||||
check-workflow-status:
|
||||
name: Check Workflow Status
|
||||
runs-on: ubuntu-26.04-arm
|
||||
@@ -412,12 +261,12 @@ jobs:
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.check-metadata.result }}" == "failure" || "${{ needs.check-metadata.result }}" == "cancelled" ]]; then
|
||||
echo "::error::Store metadata length check failed"
|
||||
echo "::error::Workflow, script or metadata checks 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
|
||||
# skipped means neither the android nor the desktop filter matched
|
||||
if [[ "${{ needs.validate-and-build.result }}" == "failure" || "${{ needs.validate-and-build.result }}" == "cancelled" ]]; then
|
||||
echo "::error::Android Check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+176
-146
@@ -3,32 +3,18 @@ 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'
|
||||
version_name:
|
||||
description: 'The version name the build carries (e.g., 2.3.0)'
|
||||
required: true
|
||||
type: string
|
||||
version_code:
|
||||
description: 'The version code the build carries'
|
||||
required: true
|
||||
type: string
|
||||
build_desktop:
|
||||
description: 'Whether to build the desktop distribution'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
build_flatpak_src:
|
||||
description: 'Whether to build the Flatpak sources'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
GSERVICES:
|
||||
required: true
|
||||
@@ -42,6 +28,8 @@ on:
|
||||
required: true
|
||||
DATADOG_CLIENT_TOKEN:
|
||||
required: true
|
||||
DATADOG_API_KEY:
|
||||
required: false
|
||||
GOOGLE_MAPS_API_KEY:
|
||||
required: true
|
||||
GOOGLE_PLAY_JSON_KEY:
|
||||
@@ -79,64 +67,19 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
jobs:
|
||||
prepare-build-info:
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 10
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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
|
||||
env:
|
||||
VERSION_CODE_OFFSET: ${{ steps.get_version_code_offset.outputs.VERSION_CODE_OFFSET }}
|
||||
run: |
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
if ! [[ "$VERSION_CODE_OFFSET" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::VERSION_CODE_OFFSET from config.properties is not numeric: '$VERSION_CODE_OFFSET'"
|
||||
exit 1
|
||||
fi
|
||||
VERSION_CODE=$((COMMIT_COUNT + VERSION_CODE_OFFSET))
|
||||
echo "versionCode=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
|
||||
release-google:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 90
|
||||
needs: [prepare-build-info]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.tag_name }}
|
||||
fetch-depth: 0
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
@@ -154,7 +97,6 @@ jobs:
|
||||
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 ./androidApp/google-services.json
|
||||
echo "$GSERVICES" > ./androidApp/google-services.json
|
||||
@@ -165,19 +107,26 @@ jobs:
|
||||
echo "datadogClientToken=$DATADOG_CLIENT_TOKEN"
|
||||
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@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
with:
|
||||
ruby-version: '4.0.7'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Build and Deploy Google Play to Internal Track with Fastlane
|
||||
# Build only: publish-play uploads the bundle once every leg has built. The Datadog
|
||||
# mapping upload runs in the same invocation so its build ID matches the bundle's.
|
||||
- name: Build the Google release
|
||||
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
|
||||
VERSION_NAME: ${{ inputs.version_name }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
DD_API_KEY: ${{ secrets.DATADOG_API_KEY }}
|
||||
run: |
|
||||
tasks=(:androidApp:bundleGoogleRelease :androidApp:assembleGoogleRelease)
|
||||
if [ -n "$DD_API_KEY" ]; then
|
||||
tasks+=(:androidApp:uploadMappingGoogleRelease)
|
||||
else
|
||||
echo "::warning::DATADOG_API_KEY is not set, so Datadog gets no R8 mapping for this release"
|
||||
fi
|
||||
./gradlew "${tasks[@]}" \
|
||||
-Pandroid.injected.version.name="$VERSION_NAME" \
|
||||
-Pandroid.injected.version.code="$VERSION_CODE" \
|
||||
-PaboutLibraries.release=true \
|
||||
-Pmeshtastic.disableAbiSplits=true
|
||||
|
||||
- name: List outputs
|
||||
run: ls -R androidApp/build/outputs/
|
||||
@@ -198,6 +147,19 @@ jobs:
|
||||
path: androidApp/build/outputs/apk/google/release/*.apk
|
||||
retention-days: 1
|
||||
|
||||
# github-release attaches it, so anyone can retrace a pasted google-flavor stack.
|
||||
- name: Compress the R8 mapping
|
||||
env:
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
run: gzip -c androidApp/build/outputs/mapping/googleRelease/mapping.txt > "mapping-google-$VERSION_CODE.txt.gz"
|
||||
|
||||
- name: Upload the R8 mapping artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: google-mapping
|
||||
path: mapping-google-*.txt.gz
|
||||
retention-days: 1
|
||||
|
||||
- name: Attest Google AAB provenance
|
||||
if: success()
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
@@ -212,15 +174,13 @@ jobs:
|
||||
|
||||
release-fdroid:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 90
|
||||
needs: [prepare-build-info]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.tag_name }}
|
||||
fetch-depth: 0
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
@@ -238,17 +198,15 @@ jobs:
|
||||
echo "$KEYSTORE" | base64 -di > "./androidApp/$KEYSTORE_FILENAME"
|
||||
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
|
||||
|
||||
- name: Setup Fastlane
|
||||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||
with:
|
||||
ruby-version: '4.0.7'
|
||||
bundler-cache: true
|
||||
|
||||
- name: Build F-Droid with Fastlane
|
||||
# No aboutLibraries.release: offlineMode keeps the output matching F-Droid's reproducible rebuild.
|
||||
- name: Build the F-Droid release
|
||||
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
|
||||
VERSION_NAME: ${{ inputs.version_name }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
run: >
|
||||
./gradlew :androidApp:assembleFdroidRelease
|
||||
-Pandroid.injected.version.name="$VERSION_NAME"
|
||||
-Pandroid.injected.version.code="$VERSION_CODE"
|
||||
|
||||
- name: List outputs
|
||||
run: ls -R androidApp/build/outputs/
|
||||
@@ -268,10 +226,8 @@ jobs:
|
||||
subject-path: androidApp/build/outputs/apk/fdroid/release/*.apk
|
||||
|
||||
release-desktop:
|
||||
if: ${{ inputs.build_desktop }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 90
|
||||
needs: [prepare-build-info]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -285,7 +241,6 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag_name }}
|
||||
fetch-depth: 0
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
@@ -301,8 +256,8 @@ jobs:
|
||||
|
||||
- name: Package Native Distributions
|
||||
env:
|
||||
ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
|
||||
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
|
||||
ORG_GRADLE_PROJECT_appVersionName: ${{ inputs.version_name }}
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
SIGN_MACOS: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY != '' && 'true' || 'false' }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
@@ -358,33 +313,6 @@ jobs:
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
# jpackage computes the .deb Depends: line from the build host's package names.
|
||||
# Ubuntu 24.04 records time_t64 transition names (libasound2t64, libpng16-16t64)
|
||||
# that don't exist on Debian 12 Bookworm / Raspberry Pi OS, making the .deb
|
||||
# uninstallable there (#5233 pinned ubuntu-22.04 for this; those runners retire
|
||||
# 2027-04, brownouts from 2026-09). Strip the t64 suffix instead: Bookworm ships
|
||||
# the plain names, and on Trixie / Ubuntu 24.04+ the t64 packages Provide them.
|
||||
# Rewriting the control file is the only host-independent fix — jpackage's
|
||||
# --linux-package-deps is additive-only, and the Compose plugin clears its
|
||||
# --resource-dir during the task, so a custom control template can't be injected.
|
||||
- name: Rewrite t64 dependency names in .deb (Debian Bookworm / Pi OS compatibility)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
for deb in desktopApp/build/compose/binaries/main-release/deb/*.deb; do
|
||||
work=$(mktemp -d)
|
||||
dpkg-deb -R "$deb" "$work"
|
||||
echo "$(basename "$deb") before: $(grep '^Depends:' "$work/DEBIAN/control")"
|
||||
sed -i -E '/^(Depends|Pre-Depends|Recommends):/ s/(lib[a-z0-9.+-]*)t64([, (]|$)/\1\2/g' "$work/DEBIAN/control"
|
||||
echo "$(basename "$deb") after: $(grep '^Depends:' "$work/DEBIAN/control")"
|
||||
if grep -nE '^[A-Za-z-]+:.*\blib[a-z0-9.+-]*t64\b' "$work/DEBIAN/control"; then
|
||||
echo "::error::t64 dependency name survived rewrite in $(basename "$deb") — extend the sed above"
|
||||
exit 1
|
||||
fi
|
||||
dpkg-deb -b --root-owner-group "$work" "$deb"
|
||||
rm -rf "$work"
|
||||
done
|
||||
|
||||
# CMP's TargetFormat.AppImage is jpackage's "app-image" — an unpacked
|
||||
# directory, not a Linux .AppImage. Wrap it into a real AppImage so
|
||||
# Linux users get a portable no-install binary alongside .deb/.rpm.
|
||||
@@ -393,7 +321,7 @@ jobs:
|
||||
- name: Build AppImage from jpackage app-image
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
APP_VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
|
||||
APP_VERSION_NAME: ${{ inputs.version_name }}
|
||||
run: scripts/build-appimage.sh
|
||||
|
||||
- name: List Desktop Binaries
|
||||
@@ -430,10 +358,8 @@ jobs:
|
||||
desktopApp/build/compose/jars/*-release.jar
|
||||
|
||||
create-flatpak-src:
|
||||
if: ${{ inputs.build_flatpak_src }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
needs: [prepare-build-info]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -446,7 +372,6 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag_name }}
|
||||
fetch-depth: 0
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
@@ -484,9 +409,8 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
release-flatpak-src:
|
||||
if: ${{ inputs.build_flatpak_src }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
needs: [create-flatpak-src]
|
||||
steps:
|
||||
- name: Download Flatpak source artifacts
|
||||
@@ -521,20 +445,62 @@ jobs:
|
||||
with:
|
||||
subject-path: flatpak-sources.json
|
||||
|
||||
github-release:
|
||||
if: ${{ !cancelled() && !failure() }}
|
||||
# The store-listing screenshots are an output of the tag: the real debug apps on an
|
||||
# emulator and on a virtual display, connected to Demo Mode's showcase mesh
|
||||
# (store-screenshots.yml), attached to the release, and published to the Play listing
|
||||
# by the production promotion. A capture failure must not lose the release or its tag
|
||||
# (the caller deletes the tag on a failed run): the capture runs soft, github-release
|
||||
# does not gate on it, and packaging falls back to the committed sets.
|
||||
store-screenshots:
|
||||
uses: ./.github/workflows/store-screenshots.yml
|
||||
with:
|
||||
ref: ${{ inputs.tag_name }}
|
||||
soft: true
|
||||
secrets: inherit
|
||||
|
||||
# Play receives the bundle only after every leg has built: a failed leg makes the caller
|
||||
# delete the tag, and Play keeps any versionCode it has been sent.
|
||||
publish-play:
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 15
|
||||
needs:
|
||||
- prepare-build-info
|
||||
- release-google
|
||||
- release-fdroid
|
||||
- release-desktop
|
||||
- release-flatpak-src
|
||||
needs: [release-google, release-fdroid, release-desktop, release-flatpak-src]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.tag_name }}
|
||||
|
||||
- name: Download the Google AAB
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: google-aab
|
||||
path: ${{ runner.temp }}/google-aab
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Decode Play Store credentials
|
||||
env:
|
||||
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
|
||||
run: printf '%s\n' "$GOOGLE_PLAY_JSON_KEY" > fastlane/play-store-credentials.json
|
||||
|
||||
- name: Upload to the internal track
|
||||
env:
|
||||
AAB: ${{ runner.temp }}/google-aab/androidApp-google-release.aab
|
||||
run: bundle exec fastlane upload_internal aab:"$AAB"
|
||||
|
||||
- name: Clean up credentials
|
||||
if: always()
|
||||
run: rm -f fastlane/play-store-credentials.json
|
||||
|
||||
github-release:
|
||||
runs-on: ubuntu-26.04-arm
|
||||
timeout-minutes: 15
|
||||
needs: [publish-play]
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -546,8 +512,9 @@ jobs:
|
||||
with:
|
||||
path: ./artifacts
|
||||
|
||||
- name: Exclude flatpak-multisrc artifacts from release
|
||||
run: rm -rf ./artifacts/flatpak-multisrc-*
|
||||
# Raw captures already uploaded stay out: store-screenshots-assets packages and attaches them.
|
||||
- name: Exclude intermediate artifacts from release
|
||||
run: rm -rf ./artifacts/flatpak-multisrc-* ./artifacts/store-screenshots-*
|
||||
|
||||
# Generate the notes ourselves with an explicit previous tag. GitHub's automatic
|
||||
# previous-release selection considers any published release, including the rolling
|
||||
@@ -560,7 +527,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ inputs.tag_name }}
|
||||
TARGET: ${{ inputs.commit_sha || github.sha }}
|
||||
TARGET: ${{ github.sha }}
|
||||
run: |
|
||||
# Fail the step if the listing itself fails — an empty PREV must only ever
|
||||
# mean "no published v* release exists yet", never a swallowed API error,
|
||||
@@ -594,9 +561,72 @@ jobs:
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # 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 }})
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: ${{ inputs.tag_name }} (${{ inputs.version_code }})
|
||||
body_path: release-notes.md
|
||||
files: ./artifacts/**/*
|
||||
draft: true
|
||||
prerelease: true
|
||||
|
||||
# One zip per flavor laid out as fastlane's images/ folder (google feeds the Play
|
||||
# listing, fdroid the committed tree F-Droid reads) and the five desktop PNGs loose,
|
||||
# so metainfo.xml can point at them by name. A flavor or the desktop set with a shot
|
||||
# missing is replaced whole by the committed one, never mixed. They are attached to
|
||||
# the draft github-release created, so the draft never waits on the emulator.
|
||||
store-screenshots-assets:
|
||||
if: ${{ !cancelled() && needs.github-release.result == 'success' }}
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
needs: [store-screenshots, github-release]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.tag_name }}
|
||||
|
||||
- name: Download the captures
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: store-screenshots-*
|
||||
path: shots
|
||||
|
||||
- name: Package the screenshots
|
||||
env:
|
||||
VERSION_CODE: ${{ inputs.version_code }}
|
||||
run: |
|
||||
mkdir -p out
|
||||
echo "### Store screenshots" >> "$GITHUB_STEP_SUMMARY"
|
||||
# Three form factors times five shots.
|
||||
for flavor in google fdroid; do
|
||||
src="shots/store-screenshots-$flavor"
|
||||
count=$(find "$src" -name '*.png' 2>/dev/null | wc -l)
|
||||
if [ "$count" -ne 15 ]; then
|
||||
echo "::warning::$flavor captured $count of 15 shots; the committed set is attached instead."
|
||||
echo "- $flavor: $count of 15 captured, committed set attached" >> "$GITHUB_STEP_SUMMARY"
|
||||
src=fastlane/metadata/android/en-US/images
|
||||
else
|
||||
echo "- $flavor: captured from the tag" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
(cd "$src" && zip -qr "$GITHUB_WORKSPACE/out/store-listing-screenshots-$flavor-${VERSION_CODE}.zip" .)
|
||||
done
|
||||
src=shots/store-screenshots-desktop
|
||||
count=$(find "$src" -name 'meshtastic-desktop-*.png' 2>/dev/null | wc -l)
|
||||
if [ "$count" -ne 5 ]; then
|
||||
echo "::warning::desktop captured $count of 5 shots; the committed set is attached instead."
|
||||
echo "- desktop: $count of 5 captured, committed set attached" >> "$GITHUB_STEP_SUMMARY"
|
||||
src=desktopApp/packaging/linux/screenshots
|
||||
else
|
||||
echo "- desktop: captured from the tag" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
cp "$src"/meshtastic-desktop-*.png out/
|
||||
|
||||
- name: Attach the screenshots to the draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ inputs.tag_name }}
|
||||
run: gh release upload "$TAG" out/* --clobber --repo "$REPO"
|
||||
@@ -58,9 +58,9 @@ jobs:
|
||||
# jobs could even enter the queue. Its two outputs are now sourced without a
|
||||
# job: cache writability is the pure expression above, and the versionCode
|
||||
# comes from one of two places:
|
||||
# - Jobs whose artifacts ship (android-check, build-desktop,
|
||||
# build-flatpak-src) check out full — blob-less — history so the build's
|
||||
# GitVersionValueSource derives the real commit-count versionCode.
|
||||
# - Jobs whose artifacts ship (android-check, build-desktop) check out full,
|
||||
# blob-less history so the build's GitVersionValueSource derives the real
|
||||
# commit-count versionCode.
|
||||
# - Validation-only jobs (lint, screenshot, test shards) pin VERSION_CODE
|
||||
# to a constant instead: their outputs are never shipped, and the real
|
||||
# value changes on every commit, which poisons the versionCode-dependent
|
||||
@@ -96,17 +96,11 @@ jobs:
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
job_summary_pr_comment: ${{ env.GRADLE_PR_COMMENT }}
|
||||
cache_read_only: ${{ env.GRADLE_CACHE_READ_ONLY }}
|
||||
# Disabled on Gradle 9.7.1: restoring an entry written by another runner crashes
|
||||
# fingerprint deserialization (IsInIdeaSyncValueSource CNFE, settings-plugin
|
||||
# classloader scope) and the job dies in ~10s before any test. A cross-machine
|
||||
# restore is the only thing that triggers it, so it is 100% red in CI and not
|
||||
# reproducible from a local store/reuse pair. Re-enable when that is fixed
|
||||
# upstream; VERSION_CODE is still pinned above so entries would reuse.
|
||||
cache_configuration_cache: 'false'
|
||||
install_jetbrains_jdk: 'true'
|
||||
|
||||
- name: Lint, Analysis & KMP Smoke Compile
|
||||
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug -Pci=true --continue
|
||||
- name: Spotless, Detekt & Android Lint
|
||||
# The root spotlessCheck and detekt do not reach the included build-logic build.
|
||||
# detektTypeResolved runs the rules that need a classpath, which plain detekt skips.
|
||||
run: ./gradlew spotlessCheck detekt detektTypeResolved :build-logic:convention:spotlessCheck :build-logic:convention:detekt :build-logic:convention:validatePlugins androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug -Pci=true -Pkotlin.daemon.useFallbackStrategy=false --continue
|
||||
|
||||
# ── Screenshot Test Validation ──────────────────────────────────────
|
||||
screenshot-check:
|
||||
@@ -133,18 +127,11 @@ jobs:
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
job_summary_pr_comment: ${{ env.GRADLE_PR_COMMENT }}
|
||||
cache_read_only: ${{ env.GRADLE_CACHE_READ_ONLY }}
|
||||
# Disabled on Gradle 9.7.1: restoring an entry written by another runner crashes
|
||||
# fingerprint deserialization (IsInIdeaSyncValueSource CNFE, settings-plugin
|
||||
# classloader scope) and the job dies in ~10s before any test. A cross-machine
|
||||
# restore is the only thing that triggers it, so it is 100% red in CI and not
|
||||
# reproducible from a local store/reuse pair. Re-enable when that is fixed
|
||||
# upstream; VERSION_CODE is still pinned above so entries would reuse.
|
||||
cache_configuration_cache: 'false'
|
||||
|
||||
- name: Screenshot Test Validation
|
||||
# -Dorg.gradle.isolated-projects=false: AGP's screenshot plugin iterates BuildServicesRegistry at
|
||||
# execution time, which Isolated Projects forbids (fatal since Gradle 9.7).
|
||||
run: ./gradlew :screenshot-tests:validateDebugScreenshotTest -Pci=true -Dorg.gradle.isolated-projects=false
|
||||
run: ./gradlew :screenshot-tests:validateDebugScreenshotTest -Pci=true -Pkotlin.daemon.useFallbackStrategy=false -Dorg.gradle.isolated-projects=false
|
||||
|
||||
- name: Upload screenshot diff report
|
||||
if: failure()
|
||||
@@ -198,8 +185,8 @@ jobs:
|
||||
# guards them against drift (every module in settings.gradle.kts with test
|
||||
# sources must appear here or be explicitly exempted).
|
||||
# shard-core: remaining core:* KMP module tests (allTests), plus kmpSmokeCompile,
|
||||
# which is the only iOS compile :core:di, :core:nfc and :core:resources
|
||||
# get. It lived in lint-check until a cold build there blew the 30m budget.
|
||||
# which is the only iOS compile :core:di and :core:resources get.
|
||||
# It lived in lint-check until a cold build there blew the 30m budget.
|
||||
# shard-feature: feature:* KMP module tests + :core:service
|
||||
# shard-app: Pure-Android/JVM tests (androidApp, desktopApp,
|
||||
# core:barcode, feature:widget)
|
||||
@@ -209,7 +196,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 35
|
||||
if: inputs.run_unit_tests == true
|
||||
env:
|
||||
VERSION_CODE: 30000000 # pinned for cache stability -- see comment above
|
||||
@@ -286,6 +273,7 @@ jobs:
|
||||
:core:database:allTests
|
||||
:core:network:allTests
|
||||
:feature:widget:testDebugUnitTest
|
||||
:schema-strings:test
|
||||
kover: >-
|
||||
:androidApp:koverXmlReportFdroidDebug
|
||||
:androidApp:koverXmlReportGoogleDebug
|
||||
@@ -310,15 +298,7 @@ jobs:
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
job_summary_pr_comment: ${{ env.GRADLE_PR_COMMENT }}
|
||||
cache_read_only: ${{ env.GRADLE_CACHE_READ_ONLY }}
|
||||
# Disabled on Gradle 9.7.1: restoring an entry written by another runner crashes
|
||||
# fingerprint deserialization (IsInIdeaSyncValueSource CNFE, settings-plugin
|
||||
# classloader scope) and the job dies in ~10s before any test. A cross-machine
|
||||
# restore is the only thing that triggers it, so it is 100% red in CI and not
|
||||
# reproducible from a local store/reuse pair. Re-enable when that is fixed
|
||||
# upstream; VERSION_CODE is still pinned above so entries would reuse.
|
||||
cache_configuration_cache: 'false'
|
||||
# Shards run different task graphs; kover flips the graph again.
|
||||
cache_key_suffix: ${{ matrix.shard.name }}${{ inputs.run_coverage && '-kover' || '' }}
|
||||
cache_konan: 'true'
|
||||
|
||||
- name: Run Tests & Coverage (${{ matrix.shard.name }})
|
||||
run: |
|
||||
@@ -329,7 +309,9 @@ jobs:
|
||||
# CCUD tags every shard with the same `CI job=test-shards`, which is the largest
|
||||
# single CI cost bucket and therefore the one worth splitting. Shard identity is
|
||||
# otherwise only recoverable by parsing the requested task list.
|
||||
./gradlew ${{ matrix.shard.tasks }} $kover_tasks -Pci=true --continue \
|
||||
# -PwarningsAsErrors: together the shards compile every main and test source set, so a new Kotlin
|
||||
# compiler warning fails here.
|
||||
./gradlew ${{ matrix.shard.tasks }} $kover_tasks -Pci=true -Pkotlin.daemon.useFallbackStrategy=false -PwarningsAsErrors=true --continue \
|
||||
"-Dscan.value.CI shard=${{ matrix.shard.name }}"
|
||||
|
||||
# A test fork that dies in native code (exit 134) names no test, and the JVM's crash report is the
|
||||
@@ -455,12 +437,13 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
# ── Android Build ────────────────────────────────────────────────────
|
||||
# Also generates the dependency graph — assembling both flavors resolves the widest set.
|
||||
# On main it also submits the dependency graph, since assembling both flavors resolves
|
||||
# the widest set.
|
||||
android-check:
|
||||
runs-on: ubuntu-26.04
|
||||
# No permissions block on purpose: inherits the caller's. main grants contents: write
|
||||
# (graph submit); PRs grant read (upload only).
|
||||
timeout-minutes: 60
|
||||
# for the graph submit.
|
||||
timeout-minutes: 30
|
||||
if: inputs.run_android_build == true
|
||||
|
||||
steps:
|
||||
@@ -478,8 +461,7 @@ jobs:
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
job_summary_pr_comment: ${{ env.GRADLE_PR_COMMENT }}
|
||||
cache_read_only: ${{ env.GRADLE_CACHE_READ_ONLY }}
|
||||
# main submits; PRs upload (no contents: write), dependency-graph-submit.yml finishes.
|
||||
dependency_graph: ${{ github.event_name == 'pull_request' && 'generate-and-upload' || (github.ref == 'refs/heads/main' && 'generate-and-submit' || 'disabled') }}
|
||||
dependency_graph: ${{ github.ref == 'refs/heads/main' && 'generate-and-submit' || 'disabled' }}
|
||||
|
||||
- name: Tag main-branch builds as -SNAPSHOT
|
||||
if: github.event_name == 'push'
|
||||
@@ -569,7 +551,7 @@ jobs:
|
||||
# via allprojects{}, which Isolated Projects forbids (fatal since Gradle 9.7). The property form is
|
||||
# used everywhere instead of --no-isolated-projects: that flag only exists on 9.7+, so the property
|
||||
# survives a pin-back below 9.7 without editing this line.
|
||||
run: ./gradlew androidApp:assembleFdroidDebug androidApp:assembleGoogleDebug -Pci=true --parallel --configuration-cache -Dorg.gradle.isolated-projects=false --continue
|
||||
run: ./gradlew androidApp:assembleFdroidDebug androidApp:assembleGoogleDebug -Pci=true -Pkotlin.daemon.useFallbackStrategy=false --parallel --configuration-cache -Dorg.gradle.isolated-projects=false --continue
|
||||
|
||||
# A dependency published for only some of our ABIs builds and installs cleanly and then
|
||||
# crashes with UnsatisfiedLinkError on the others (#7001). Compare the splits here, where
|
||||
@@ -603,7 +585,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 35
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -624,7 +606,6 @@ jobs:
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
job_summary_pr_comment: ${{ env.GRADLE_PR_COMMENT }}
|
||||
cache_read_only: ${{ env.GRADLE_CACHE_READ_ONLY }}
|
||||
install_jetbrains_jdk: 'true'
|
||||
|
||||
- name: Install dependencies for AppImage
|
||||
if: runner.os == 'Linux'
|
||||
@@ -652,8 +633,9 @@ jobs:
|
||||
# --no-configuration-cache while IP is still on. Flags, not -D properties —
|
||||
# -Dorg.gradle.isolated-projects=false is inert on windows-latest. --no-isolated-projects
|
||||
# needs Gradle 9.7+, so a pin-back below 9.7 has to revert this line with it.
|
||||
# The daemon flag is quoted because PowerShell splits an unquoted -P argument at its first dot.
|
||||
run: >
|
||||
./gradlew :desktopApp:packageDistributionForCurrentOS :desktopApp:proguardReleaseJars -Pci=true
|
||||
./gradlew :desktopApp:packageDistributionForCurrentOS :desktopApp:proguardReleaseJars -Pci=true "-Pkotlin.daemon.useFallbackStrategy=false"
|
||||
${{ runner.os == 'Windows' && '--no-isolated-projects --no-configuration-cache' || '' }}
|
||||
|
||||
# CMP's TargetFormat.AppImage is jpackage's "app-image" — an unpacked directory, not
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
# Once a day. Baseline-profile regeneration needs a booted emulator (~14 min)
|
||||
# and the profile/graphs rarely change, so this is decoupled from the frequent
|
||||
# firmware/hardware/translation job in scheduled-updates.yml.
|
||||
- cron: '0 0 * * *'
|
||||
- cron: '23 2 * * *'
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
# Emulator runs take up to ~1 h; a manual dispatch overlapping the daily cron
|
||||
@@ -66,23 +66,22 @@ jobs:
|
||||
profile: pixel_6
|
||||
disable-animations: true
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
# Writes androidApp/src/<variant>/generated/baselineProfiles/ via the androidx.baselineprofile plugin.
|
||||
# The variant is googleRelease (flavor + buildType), NOT the bare `google` flavor dir.
|
||||
# Writes androidApp/src/main/generated/baselineProfiles/ via the androidx.baselineprofile plugin:
|
||||
# :androidApp sets mergeIntoMain, which is what makes the fdroid flavor ship the profile too.
|
||||
# --no-configuration-cache: the underlying connectedGoogleNonMinifiedReleaseAndroidTest task is not
|
||||
# config-cache serializable (SeparateTestModuleTestData / ResolutionBackedFileCollection), and the
|
||||
# project enables org.gradle.configuration-cache by default — same workaround used in reusable-check.yml.
|
||||
# -Dorg.gradle.isolated-projects=false must accompany it: Isolated Projects implies the configuration cache, and
|
||||
# Gradle 9.7+ hard-errors when the cache is disabled while Isolated Projects is on.
|
||||
script: ./gradlew :androidApp:generateGoogleReleaseBaselineProfile -Pci=true -Dorg.gradle.isolated-projects=false --no-configuration-cache
|
||||
script: ./gradlew :androidApp:generateBaselineProfile -Pci=true -Dorg.gradle.isolated-projects=false --no-configuration-cache
|
||||
|
||||
- name: Detect baseline profile changes
|
||||
id: baseline
|
||||
run: |
|
||||
outcome="${{ steps.generate_baseline.outcome }}"
|
||||
# Pin the variant the Gradle task above targets: googleRelease (flavor + buildType), NOT the
|
||||
# bare `google` flavor dir. Searching for any baseline-prof.txt would happily validate another
|
||||
# variant's file — or, once this profile is committed, the stale one already in the checkout.
|
||||
profile_dir="androidApp/src/googleRelease/generated/baselineProfiles"
|
||||
# Pin the directory mergeIntoMain writes. Searching for any baseline-prof.txt would happily
|
||||
# validate a file from some other source set, or the stale one already in the checkout.
|
||||
profile_dir="androidApp/src/main/generated/baselineProfiles"
|
||||
profile="$profile_dir/baseline-prof.txt"
|
||||
if [ "$outcome" != "success" ]; then
|
||||
echo "::error::Baseline profile generation failed (outcome: $outcome)."
|
||||
@@ -129,7 +128,38 @@ jobs:
|
||||
echo "PREOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# GitHub refuses a push to the head branch of a queued PR (GH006), so a queued PR is left to land.
|
||||
# Same check as scheduled-updates.yml, keyed on this workflow's branch.
|
||||
- name: Check whether the scheduled PR is in the merge queue
|
||||
id: merge_queue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
query=$(cat <<'GQL'
|
||||
query($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequests(headRefName: "scheduled-baseline", baseRefName: "main", states: OPEN, first: 50) {
|
||||
nodes { number isCrossRepository isInMergeQueue }
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
)
|
||||
if ! queued=$(gh api graphql -f query="$query" -f owner="$GITHUB_REPOSITORY_OWNER" -f name="${GITHUB_REPOSITORY#*/}" \
|
||||
--jq '.data.repository.pullRequests.nodes[] | select(.isInMergeQueue and (.isCrossRepository | not)) | .number'); then
|
||||
echo "::warning::Could not read the merge queue state of the scheduled PR; updating it anyway."
|
||||
queued=""
|
||||
fi
|
||||
if [ -n "$queued" ]; then
|
||||
echo "::notice::#$queued is in the merge queue; leaving its branch alone until it lands."
|
||||
echo "in_queue=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "in_queue=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create Pull Request if changes occurred
|
||||
id: pr
|
||||
if: steps.merge_queue.outputs.in_queue != 'true'
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
@@ -154,6 +184,26 @@ jobs:
|
||||
labels: |
|
||||
automation
|
||||
|
||||
# "Merge when ready", as in scheduled-updates.yml: the queue sets the merge method, a fresh push can
|
||||
# clear an earlier request, and an already-mergeable PR is refused the request so it merges directly.
|
||||
- name: Enable auto-merge
|
||||
if: ${{ steps.merge_queue.outputs.in_queue != 'true' && (steps.pr.outputs.pull-request-operation == 'created' || steps.pr.outputs.pull-request-operation == 'updated') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pull-request-number }}
|
||||
run: |
|
||||
PR_ID=$(gh pr view "$PR_NUMBER" --json id --jq .id)
|
||||
QUERY=$(cat <<'GQL'
|
||||
mutation($id: ID!) {
|
||||
enablePullRequestAutoMerge(input: { pullRequestId: $id }) {
|
||||
pullRequest { number autoMergeRequest { enabledAt } }
|
||||
}
|
||||
}
|
||||
GQL
|
||||
)
|
||||
gh api graphql -f query="$QUERY" -F id="$PR_ID" \
|
||||
|| gh pr merge "$PR_NUMBER"
|
||||
|
||||
# Runs after the PR so graph updates still land, but turns the run red: a silently-green
|
||||
# baseline job let a path mismatch discard a freshly generated profile every night for weeks.
|
||||
- name: Fail the run if baseline generation broke
|
||||
|
||||
@@ -2,12 +2,16 @@ name: Scheduled Updates (Firmware, Hardware, Translations)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Hourly. This job is cheap (curl + Crowdin + PR, no build) — the slow
|
||||
# emulator/graph work lives in scheduled-baseline.yml on a daily cron.
|
||||
- cron: '0 * * * *'
|
||||
# Cheap (curl + Crowdin + PR; Gradle only when the protobufs pin moves). The emulator and
|
||||
# graph work lives in scheduled-baseline.yml.
|
||||
- cron: '17 */6 * * *'
|
||||
# A protobufs pin change on main regenerates the schema strings without waiting for the cron.
|
||||
push:
|
||||
branches: [main]
|
||||
paths: [gradle/libs.versions.toml]
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
# Hourly cron + manual dispatch must never stack: overlapping runs race on the
|
||||
# Cron, push and manual dispatch must never stack: overlapping runs race on the
|
||||
# scheduled-updates branch force-push. Later runs queue (at most one pending).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
@@ -30,213 +34,27 @@ jobs:
|
||||
|
||||
- name: Update firmware releases list
|
||||
id: firmware
|
||||
run: |
|
||||
firmware_file_path="androidApp/src/main/assets/firmware_releases.json"
|
||||
temp_firmware_file="/tmp/new_firmware_releases.json"
|
||||
|
||||
echo "Fetching latest firmware releases..."
|
||||
http_code=$(curl -s -o "$temp_firmware_file" -w '%{http_code}' https://api.meshtastic.org/github/firmware/list || true)
|
||||
http_code="${http_code:-0}"
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "::warning::Firmware API returned HTTP $http_code. Skipping firmware update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=HTTP $http_code from firmware API" >> "$GITHUB_OUTPUT"
|
||||
elif ! jq empty "$temp_firmware_file" 2>/dev/null; then
|
||||
echo "::warning::Firmware API returned invalid JSON data. Skipping firmware update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=Invalid JSON response from firmware API" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Drop `.pullRequests` before diffing and before writing: the API lists every open
|
||||
# firmware PR, which turns over several times a day, and the app never reads the field
|
||||
# (see NetworkFirmwareReleases). Keeping it churned an hourly PR carrying no real change.
|
||||
if [ ! -f "$firmware_file_path" ] || ! jq --sort-keys 'del(.pullRequests)' "$temp_firmware_file" | diff -q - <(jq --sort-keys 'del(.pullRequests)' "$firmware_file_path"); then
|
||||
echo "Changes detected in firmware list or local file missing. Updating $firmware_file_path."
|
||||
jq 'del(.pullRequests)' "$temp_firmware_file" > "$firmware_file_path"
|
||||
echo "status=updated" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No changes detected in firmware list."
|
||||
echo "status=unchanged" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
run: scripts/scheduled-updates/assets.sh fetch firmware
|
||||
|
||||
- name: Update hardware list
|
||||
id: hardware
|
||||
run: |
|
||||
hardware_file_path="androidApp/src/main/assets/device_hardware.json"
|
||||
temp_hardware_file="/tmp/new_device_hardware.json"
|
||||
|
||||
echo "Fetching latest device hardware data..."
|
||||
http_code=$(curl -s -o "$temp_hardware_file" -w '%{http_code}' https://api.meshtastic.org/resource/deviceHardware || true)
|
||||
http_code="${http_code:-0}"
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "::warning::Hardware API returned HTTP $http_code. Skipping hardware update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=HTTP $http_code from hardware API" >> "$GITHUB_OUTPUT"
|
||||
elif ! jq empty "$temp_hardware_file" 2>/dev/null; then
|
||||
echo "::warning::Hardware API returned invalid JSON data. Skipping hardware update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=Invalid JSON response from hardware API" >> "$GITHUB_OUTPUT"
|
||||
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"
|
||||
echo "status=updated" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No changes detected in hardware list."
|
||||
echo "status=unchanged" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
run: scripts/scheduled-updates/assets.sh fetch hardware
|
||||
|
||||
- name: Update event firmware metadata
|
||||
id: event_firmware
|
||||
run: |
|
||||
event_firmware_file_path="androidApp/src/main/assets/event_firmware.json"
|
||||
temp_event_firmware_file="/tmp/new_event_firmware.json"
|
||||
|
||||
echo "Fetching latest event firmware metadata..."
|
||||
http_code=$(curl -s --max-time 90 -o "$temp_event_firmware_file" -w '%{http_code}' https://api.meshtastic.org/resource/eventFirmware || true)
|
||||
http_code="${http_code:-0}"
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "::warning::Event firmware API returned HTTP $http_code. Skipping event firmware update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=HTTP $http_code from event firmware API" >> "$GITHUB_OUTPUT"
|
||||
elif ! jq empty "$temp_event_firmware_file" 2>/dev/null; then
|
||||
echo "::warning::Event firmware API returned invalid JSON data. Skipping event firmware update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=Invalid JSON response from event firmware API" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
if [ ! -f "$event_firmware_file_path" ] || ! jq --sort-keys . "$temp_event_firmware_file" | diff -q - <(jq --sort-keys . "$event_firmware_file_path"); then
|
||||
echo "Changes detected in event firmware metadata or local file missing. Updating $event_firmware_file_path."
|
||||
cp "$temp_event_firmware_file" "$event_firmware_file_path"
|
||||
echo "status=updated" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No changes detected in event firmware metadata."
|
||||
echo "status=unchanged" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
run: scripts/scheduled-updates/assets.sh fetch event_firmware
|
||||
|
||||
- name: Update device links list
|
||||
id: device_links
|
||||
run: |
|
||||
device_links_file_path="androidApp/src/main/assets/device_links.json"
|
||||
temp_device_links_file="/tmp/new_device_links.json"
|
||||
|
||||
echo "Fetching latest device links..."
|
||||
http_code=$(curl -s --max-time 90 -o "$temp_device_links_file" -w '%{http_code}' https://api.meshtastic.org/resource/deviceLinks || true)
|
||||
http_code="${http_code:-0}"
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "::warning::Device links API returned HTTP $http_code. Skipping device links update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=HTTP $http_code from device links API" >> "$GITHUB_OUTPUT"
|
||||
elif ! jq empty "$temp_device_links_file" 2>/dev/null; then
|
||||
echo "::warning::Device links API returned invalid JSON data. Skipping device links update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=Invalid JSON response from device links API" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# This file is only the offline seed for the runtime cache, which refreshes
|
||||
# itself from the same endpoint on a TTL. Guard against a degraded response
|
||||
# (HTTP 200 but empty catalog) wiping the seed — mirrors the runtime, which
|
||||
# ignores empty responses for the same reason.
|
||||
link_count=$(jq -r 'if (.links | type) == "array" then (.links | length) else 0 end' "$temp_device_links_file" 2>/dev/null || echo 0)
|
||||
if [ "$link_count" -eq 0 ]; then
|
||||
echo "::warning::Device links API returned no links. Skipping to protect the bundled seed."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=empty links array from device links API" >> "$GITHUB_OUTPUT"
|
||||
# Diff on `.links` only: the envelope carries a server-set `generatedAt`
|
||||
# timestamp that changes on every response, so an hourly whole-document diff
|
||||
# would open a churn PR even when the catalog is unchanged.
|
||||
elif [ ! -f "$device_links_file_path" ] || ! jq --sort-keys '.links' "$temp_device_links_file" | diff -q - <(jq --sort-keys '.links' "$device_links_file_path"); then
|
||||
echo "Changes detected in device links or local file missing. Updating $device_links_file_path."
|
||||
# Pretty-print so the committed asset stays readable and future diffs stay
|
||||
# minimal regardless of the API's response formatting.
|
||||
jq . "$temp_device_links_file" > "$device_links_file_path"
|
||||
echo "status=updated" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No changes detected in device links."
|
||||
echo "status=unchanged" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
run: scripts/scheduled-updates/assets.sh fetch device_links
|
||||
|
||||
- name: Update bootloader OTA quirks
|
||||
id: bootloader_quirks
|
||||
run: |
|
||||
quirks_file_path="androidApp/src/main/assets/device_bootloader_ota_quirks.json"
|
||||
temp_quirks_file="/tmp/new_device_bootloader_ota_quirks.json"
|
||||
|
||||
echo "Fetching latest bootloader OTA quirks..."
|
||||
http_code=$(curl -s --max-time 90 -o "$temp_quirks_file" -w '%{http_code}' https://api.meshtastic.org/resource/bootloaderOtaQuirks || true)
|
||||
http_code="${http_code:-0}"
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "::warning::Bootloader quirks API returned HTTP $http_code. Skipping quirks update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=HTTP $http_code from bootloader quirks API" >> "$GITHUB_OUTPUT"
|
||||
elif ! jq empty "$temp_quirks_file" 2>/dev/null; then
|
||||
echo "::warning::Bootloader quirks API returned invalid JSON data. Skipping quirks update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=Invalid JSON response from bootloader quirks API" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# This file is the offline seed for the runtime cache. Its softDeviceVariants
|
||||
# table gates a destructive flash and must fail closed, so a degraded response
|
||||
# (HTTP 200 but an empty table) must never wipe the seed.
|
||||
variant_count=$(jq -r 'if (.softDeviceVariants | type) == "array" then (.softDeviceVariants | length) else 0 end' "$temp_quirks_file" 2>/dev/null || echo 0)
|
||||
if [ "$variant_count" -eq 0 ]; then
|
||||
echo "::warning::Bootloader quirks API returned no softDeviceVariants. Skipping to protect the bundled seed."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=empty softDeviceVariants from bootloader quirks API" >> "$GITHUB_OUTPUT"
|
||||
elif [ ! -f "$quirks_file_path" ] || ! jq --sort-keys . "$temp_quirks_file" | diff -q - <(jq --sort-keys . "$quirks_file_path"); then
|
||||
echo "Changes detected in bootloader quirks or local file missing. Updating $quirks_file_path."
|
||||
jq . "$temp_quirks_file" > "$quirks_file_path"
|
||||
echo "status=updated" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No changes detected in bootloader quirks."
|
||||
echo "status=unchanged" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
run: scripts/scheduled-updates/assets.sh fetch bootloader_quirks
|
||||
|
||||
- name: Update maintenance UF2 manifest
|
||||
id: maintenance_uf2
|
||||
run: |
|
||||
uf2_file_path="androidApp/src/main/assets/maintenance_uf2.json"
|
||||
temp_uf2_file="/tmp/new_maintenance_uf2.json"
|
||||
|
||||
echo "Fetching latest maintenance UF2 manifest..."
|
||||
http_code=$(curl -s --max-time 90 -o "$temp_uf2_file" -w '%{http_code}' https://api.meshtastic.org/resource/maintenanceUf2 || true)
|
||||
http_code="${http_code:-0}"
|
||||
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "::warning::Maintenance UF2 API returned HTTP $http_code. Skipping manifest update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=HTTP $http_code from maintenance UF2 API" >> "$GITHUB_OUTPUT"
|
||||
elif ! jq empty "$temp_uf2_file" 2>/dev/null; then
|
||||
echo "::warning::Maintenance UF2 API returned invalid JSON data. Skipping manifest update."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=Invalid JSON response from maintenance UF2 API" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# The manifest's digest-pinned images gate destructive maintenance flashes;
|
||||
# a degraded response (missing erase set or empty board map) must never
|
||||
# replace the bundled seed.
|
||||
board_count=$(jq -r 'if (.otafixByBoardId | type) == "object" then (.otafixByBoardId | length) else 0 end' "$temp_uf2_file" 2>/dev/null || echo 0)
|
||||
# .erase.nrf52Bootloader is required too: the bundled seed carries it, and a manifest without it
|
||||
# would silently drop the bootloader-driven erase path from every offline install.
|
||||
has_erase=$(jq -r 'if (.erase | type) == "object" and (.erase.nrf52Bootloader | type) == "object" then 1 else 0 end' "$temp_uf2_file" 2>/dev/null || echo 0)
|
||||
if [ "$board_count" -eq 0 ] || [ "$has_erase" -ne 1 ]; then
|
||||
echo "::warning::Maintenance UF2 API response is missing the erase set (incl. erase.nrf52Bootloader) or board map. Skipping to protect the bundled seed."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=degraded maintenance UF2 manifest (no erase set, no erase.nrf52Bootloader, or empty board map)" >> "$GITHUB_OUTPUT"
|
||||
elif [ ! -f "$uf2_file_path" ] || ! jq --sort-keys . "$temp_uf2_file" | diff -q - <(jq --sort-keys . "$uf2_file_path"); then
|
||||
echo "Changes detected in maintenance UF2 manifest or local file missing. Updating $uf2_file_path."
|
||||
jq . "$temp_uf2_file" > "$uf2_file_path"
|
||||
echo "status=updated" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No changes detected in maintenance UF2 manifest."
|
||||
echo "status=unchanged" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
run: scripts/scheduled-updates/assets.sh fetch maintenance_uf2
|
||||
|
||||
- name: Refresh Obtainium configs and deep links
|
||||
id: obtainium
|
||||
@@ -264,8 +82,84 @@ jobs:
|
||||
git checkout -- README.md docs/en/developer/test-builds.md obtainium
|
||||
fi
|
||||
|
||||
# values/schema_strings.xml is generated from the org.meshtastic:protobufs pin and records which one. Gradle
|
||||
# runs here only when main's pin has moved past the recorded one and the scheduled-updates branch does not
|
||||
# already carry the file for that pin, so the job stays cheap between bumps and while the PR waits;
|
||||
# the regenerated English goes up to Crowdin in the step below and rides this PR with the translations.
|
||||
- name: Check whether the schema strings lag the protobufs pin
|
||||
id: schema_pin
|
||||
run: |
|
||||
xml=core/resources/src/commonMain/composeResources/values/schema_strings.xml
|
||||
strings=core/resources/src/commonMain/composeResources/values/strings.xml
|
||||
recorded_pin() { sed -n 's/.*from org\.meshtastic:protobufs \([^ ]*\)\..*/\1/p' | head -n1; }
|
||||
xml_header() { sed '/<resources>/q'; }
|
||||
catalog=$(sed -n 's/^meshtastic-protobufs = "\(.*\)"$/\1/p' gradle/libs.versions.toml)
|
||||
recorded=$(recorded_pin < "$xml")
|
||||
echo "catalog=$catalog" >> "$GITHUB_OUTPUT"
|
||||
echo "recorded=$recorded" >> "$GITHUB_OUTPUT"
|
||||
if [ "$catalog" = "$recorded" ]; then
|
||||
echo "schema strings already reflect protobufs $catalog"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "schema strings reflect protobufs $recorded, catalog has $catalog"
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
branch_xml="$RUNNER_TEMP/scheduled-updates.schema_strings.xml"
|
||||
# The file is a function of the pin, the generator in schema-strings/ and the strings.xml header it
|
||||
# borrows, so a copy built before either of the last two changed on main is regenerated.
|
||||
if git fetch --no-tags --depth=1 --quiet origin scheduled-updates 2>/dev/null \
|
||||
&& git show "FETCH_HEAD:$xml" > "$branch_xml" 2>/dev/null \
|
||||
&& [ "$(recorded_pin < "$branch_xml")" = "$catalog" ] \
|
||||
&& [ "$(git rev-parse FETCH_HEAD:schema-strings 2>/dev/null)" = "$(git rev-parse HEAD:schema-strings)" ] \
|
||||
&& [ "$(xml_header < "$branch_xml")" = "$(xml_header < "$strings")" ]; then
|
||||
echo "scheduled-updates already carries the schema strings for protobufs $catalog; reusing them"
|
||||
echo "reuse=$branch_xml" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Gradle Setup
|
||||
if: steps.schema_pin.outputs.changed == 'true' && steps.schema_pin.outputs.reuse == ''
|
||||
uses: ./.github/actions/gradle-setup
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
job_summary_pr_comment: 'never'
|
||||
cache_read_only: 'true'
|
||||
cache_robolectric: 'false'
|
||||
|
||||
- name: Regenerate the schema strings
|
||||
id: schema_strings
|
||||
if: steps.schema_pin.outputs.changed == 'true'
|
||||
env:
|
||||
RECORDED: ${{ steps.schema_pin.outputs.recorded }}
|
||||
CATALOG: ${{ steps.schema_pin.outputs.catalog }}
|
||||
REUSE: ${{ steps.schema_pin.outputs.reuse }}
|
||||
run: |
|
||||
xml=core/resources/src/commonMain/composeResources/values/schema_strings.xml
|
||||
cp "$xml" /tmp/schema_strings.before.xml
|
||||
# Only the xml is taken from the branch: strings-index.txt also lists main's hand-written strings,
|
||||
# which may have moved since the branch was built, so sort-strings.py rebuilds it either way.
|
||||
if [ -n "$REUSE" ]; then
|
||||
regenerate=(cp "$REUSE" "$xml")
|
||||
else
|
||||
regenerate=(./gradlew :schema-strings:sync -Pci=true)
|
||||
fi
|
||||
if "${regenerate[@]}" && python3 scripts/sort-strings.py; then
|
||||
git clone --filter=blob:none --quiet https://github.com/meshtastic/protobufs.git /tmp/protobufs
|
||||
python3 scripts/protobufs-bump-summary.py "$RECORDED" "$CATALOG" --protobufs /tmp/protobufs \
|
||||
--before /tmp/schema_strings.before.xml --after "$xml" > /tmp/schema-summary.md || true
|
||||
cat /tmp/schema-summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
detail=$(sed -n 's/^\*\*\([0-9]* [a-z]* strings\)\*\*.*/\1/p' /tmp/schema-summary.md | paste -sd, - | sed 's/,/, /g')
|
||||
echo "status=updated" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=${detail:-no string changes}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::Schema strings sync failed — see the error above."
|
||||
echo "status=error" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=./gradlew :schema-strings:sync failed for protobufs $CATALOG" >> "$GITHUB_OUTPUT"
|
||||
git checkout -- "$xml" .skills/compose-ui/strings-index.txt
|
||||
fi
|
||||
|
||||
- name: Sync with Crowdin
|
||||
uses: crowdin/github-action@9af557de76d70c480f88065d336f445a362f402b # v3
|
||||
uses: crowdin/github-action@9c23991700c0ec5256fd41089b9d9d7d540e424e # v3.3.0
|
||||
with:
|
||||
base_url: 'https://meshtastic.crowdin.com/api/v2'
|
||||
config: 'crowdin.yml'
|
||||
@@ -273,18 +167,21 @@ jobs:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
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 }}
|
||||
# Runs the container as the owner of .git, so the downloaded files are not root-owned.
|
||||
user: 'auto'
|
||||
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" .
|
||||
# Crowdin translates layout and nav_order along with the prose and carries over the English
|
||||
# parent. This restores the first two from docs/en and drops parent; docs-quality.yml fails
|
||||
# the PR on any left, so a failure here must not stop the PR opening.
|
||||
- name: Restore locale docs front matter
|
||||
continue-on-error: true
|
||||
run: python3 scripts/docs/sync-locale-front-matter.py
|
||||
|
||||
# Early warning for store-listing translations just pulled from Crowdin
|
||||
# (overlength text, unmapped locale codes, forbidden HTML). Non-blocking on
|
||||
@@ -295,6 +192,40 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: python3 scripts/check-store-metadata.py
|
||||
|
||||
# Each label fires a labeler run, so a category is labelled only when this run changed it.
|
||||
- name: Pick the PR labels
|
||||
id: labels
|
||||
env:
|
||||
FIRMWARE_STATUS: ${{ steps.firmware.outputs.status }}
|
||||
EVENT_FIRMWARE_STATUS: ${{ steps.event_firmware.outputs.status }}
|
||||
MAINTENANCE_UF2_STATUS: ${{ steps.maintenance_uf2.outputs.status }}
|
||||
HARDWARE_STATUS: ${{ steps.hardware.outputs.status }}
|
||||
DEVICE_LINKS_STATUS: ${{ steps.device_links.outputs.status }}
|
||||
BOOTLOADER_QUIRKS_STATUS: ${{ steps.bootloader_quirks.outputs.status }}
|
||||
SCHEMA_STRINGS_STATUS: ${{ steps.schema_strings.outputs.status }}
|
||||
run: |
|
||||
any_updated() {
|
||||
for status in "$@"; do [ "$status" = updated ] && return 0; done
|
||||
return 1
|
||||
}
|
||||
labels=automation
|
||||
if any_updated "$FIRMWARE_STATUS" "$EVENT_FIRMWARE_STATUS" "$MAINTENANCE_UF2_STATUS"; then
|
||||
labels+=,firmware
|
||||
fi
|
||||
if any_updated "$HARDWARE_STATUS" "$DEVICE_LINKS_STATUS" "$BOOTLOADER_QUIRKS_STATUS"; then
|
||||
labels+=,hardware
|
||||
fi
|
||||
# Crowdin reports no status, so its downloads are read off the tree: the locale copies, never the English sources.
|
||||
translations=$(git status --porcelain --untracked-files=all -- \
|
||||
':(glob)**/composeResources/values-*/**' \
|
||||
':(glob)fastlane/metadata/android/**' ':(exclude,glob)fastlane/metadata/android/en-US/**' \
|
||||
':(glob)docs/**' ':(exclude,glob)docs/en/**')
|
||||
if [ -n "$translations" ] || any_updated "$SCHEMA_STRINGS_STATUS"; then
|
||||
labels+=,l10n
|
||||
fi
|
||||
echo "Labels: $labels"
|
||||
echo "labels=$labels" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build PR body
|
||||
id: pr_body
|
||||
env:
|
||||
@@ -314,95 +245,45 @@ jobs:
|
||||
MAINTENANCE_UF2_DETAIL: ${{ steps.maintenance_uf2.outputs.detail }}
|
||||
OBTAINIUM_STATUS: ${{ steps.obtainium.outputs.status }}
|
||||
OBTAINIUM_DETAIL: ${{ steps.obtainium.outputs.detail }}
|
||||
SCHEMA_PIN_CHANGED: ${{ steps.schema_pin.outputs.changed }}
|
||||
SCHEMA_PIN_CATALOG: ${{ steps.schema_pin.outputs.catalog }}
|
||||
SCHEMA_STRINGS_STATUS: ${{ steps.schema_strings.outputs.status }}
|
||||
SCHEMA_STRINGS_DETAIL: ${{ steps.schema_strings.outputs.detail }}
|
||||
run: scripts/scheduled-updates/assets.sh body
|
||||
|
||||
# GitHub refuses a push to the head branch of a queued PR (GH006), so a queued PR is left to land.
|
||||
# gh pr view --json does not expose isInMergeQueue; GraphQL does.
|
||||
# headRefName also matches fork PRs; only one open same-repository PR can exist per head and base.
|
||||
- name: Check whether the scheduled PR is in the merge queue
|
||||
id: merge_queue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
firmware_status="$FIRMWARE_STATUS"
|
||||
firmware_detail="$FIRMWARE_DETAIL"
|
||||
hardware_status="$HARDWARE_STATUS"
|
||||
hardware_detail="$HARDWARE_DETAIL"
|
||||
event_firmware_status="$EVENT_FIRMWARE_STATUS"
|
||||
event_firmware_detail="$EVENT_FIRMWARE_DETAIL"
|
||||
device_links_status="$DEVICE_LINKS_STATUS"
|
||||
device_links_detail="$DEVICE_LINKS_DETAIL"
|
||||
bootloader_quirks_status="$BOOTLOADER_QUIRKS_STATUS"
|
||||
bootloader_quirks_detail="$BOOTLOADER_QUIRKS_DETAIL"
|
||||
maintenance_uf2_status="$MAINTENANCE_UF2_STATUS"
|
||||
maintenance_uf2_detail="$MAINTENANCE_UF2_DETAIL"
|
||||
obtainium_status="$OBTAINIUM_STATUS"
|
||||
obtainium_detail="$OBTAINIUM_DETAIL"
|
||||
|
||||
body="This PR includes automated updates from the scheduled workflow:"
|
||||
body+=$'\n'
|
||||
|
||||
# Firmware status
|
||||
case "$firmware_status" in
|
||||
updated) body+=$'\n'"- ✅ \`firmware_releases.json\` updated from the Meshtastic API." ;;
|
||||
unchanged) body+=$'\n'"- ✔️ \`firmware_releases.json\` checked — no changes detected." ;;
|
||||
error) body+=$'\n'"- ⚠️ \`firmware_releases.json\` skipped — ${firmware_detail}." ;;
|
||||
*) body+=$'\n'"- ❓ \`firmware_releases.json\` — unknown status." ;;
|
||||
esac
|
||||
|
||||
# Hardware status
|
||||
case "$hardware_status" in
|
||||
updated) body+=$'\n'"- ✅ \`device_hardware.json\` updated from the Meshtastic API." ;;
|
||||
unchanged) body+=$'\n'"- ✔️ \`device_hardware.json\` checked — no changes detected." ;;
|
||||
error) body+=$'\n'"- ⚠️ \`device_hardware.json\` skipped — ${hardware_detail}." ;;
|
||||
*) body+=$'\n'"- ❓ \`device_hardware.json\` — unknown status." ;;
|
||||
esac
|
||||
|
||||
# Event firmware status
|
||||
case "$event_firmware_status" in
|
||||
updated) body+=$'\n'"- ✅ \`event_firmware.json\` updated from the Meshtastic API." ;;
|
||||
unchanged) body+=$'\n'"- ✔️ \`event_firmware.json\` checked — no changes detected." ;;
|
||||
error) body+=$'\n'"- ⚠️ \`event_firmware.json\` skipped — ${event_firmware_detail}." ;;
|
||||
*) body+=$'\n'"- ❓ \`event_firmware.json\` — unknown status." ;;
|
||||
esac
|
||||
|
||||
# Device links status
|
||||
case "$device_links_status" in
|
||||
updated) body+=$'\n'"- ✅ \`device_links.json\` updated from the Meshtastic API." ;;
|
||||
unchanged) body+=$'\n'"- ✔️ \`device_links.json\` checked — no changes detected." ;;
|
||||
error) body+=$'\n'"- ⚠️ \`device_links.json\` skipped — ${device_links_detail}." ;;
|
||||
*) body+=$'\n'"- ❓ \`device_links.json\` — unknown status." ;;
|
||||
esac
|
||||
|
||||
# Bootloader OTA quirks status
|
||||
case "$bootloader_quirks_status" in
|
||||
updated) body+=$'\n'"- ✅ \`device_bootloader_ota_quirks.json\` updated from the Meshtastic API." ;;
|
||||
unchanged) body+=$'\n'"- ✔️ \`device_bootloader_ota_quirks.json\` checked — no changes detected." ;;
|
||||
error) body+=$'\n'"- ⚠️ \`device_bootloader_ota_quirks.json\` skipped — ${bootloader_quirks_detail}." ;;
|
||||
*) body+=$'\n'"- ❓ \`device_bootloader_ota_quirks.json\` — unknown status." ;;
|
||||
esac
|
||||
|
||||
# Maintenance UF2 manifest status
|
||||
case "$maintenance_uf2_status" in
|
||||
updated) body+=$'\n'"- ✅ \`maintenance_uf2.json\` updated from the Meshtastic API." ;;
|
||||
unchanged) body+=$'\n'"- ✔️ \`maintenance_uf2.json\` checked — no changes detected." ;;
|
||||
error) body+=$'\n'"- ⚠️ \`maintenance_uf2.json\` skipped — ${maintenance_uf2_detail}." ;;
|
||||
*) body+=$'\n'"- ❓ \`maintenance_uf2.json\` — unknown status." ;;
|
||||
esac
|
||||
|
||||
# Obtainium configs / deep links
|
||||
case "$obtainium_status" in
|
||||
updated) body+=$'\n'"- ✅ Obtainium deep links and import files regenerated (channel status changed)." ;;
|
||||
unchanged) body+=$'\n'"- ✔️ Obtainium configs checked against the live releases — no changes detected." ;;
|
||||
error) body+=$'\n'"- ⚠️ Obtainium configs skipped — ${obtainium_detail}." ;;
|
||||
*) body+=$'\n'"- ❓ Obtainium configs — unknown status." ;;
|
||||
esac
|
||||
|
||||
# Crowdin (always attempted)
|
||||
body+=$'\n'"- Source strings were uploaded to Crowdin."
|
||||
body+=$'\n'"- Latest translations were downloaded from Crowdin (if available)."
|
||||
body+=$'\n'
|
||||
body+=$'\n'"Please review the changes."
|
||||
|
||||
# Write multi-line body to output
|
||||
{
|
||||
echo "content<<PREOF"
|
||||
echo "$body"
|
||||
echo "PREOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
query=$(cat <<'GQL'
|
||||
query($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequests(headRefName: "scheduled-updates", baseRefName: "main", states: OPEN, first: 50) {
|
||||
nodes { number isCrossRepository isInMergeQueue }
|
||||
}
|
||||
}
|
||||
}
|
||||
GQL
|
||||
)
|
||||
if ! queued=$(gh api graphql -f query="$query" -f owner="$GITHUB_REPOSITORY_OWNER" -f name="${GITHUB_REPOSITORY#*/}" \
|
||||
--jq '.data.repository.pullRequests.nodes[] | select(.isInMergeQueue and (.isCrossRepository | not)) | .number'); then
|
||||
echo "::warning::Could not read the merge queue state of the scheduled PR; updating it anyway."
|
||||
queued=""
|
||||
fi
|
||||
if [ -n "$queued" ]; then
|
||||
echo "::notice::#$queued is in the merge queue; leaving its branch alone until it lands."
|
||||
echo "in_queue=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "in_queue=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create Pull Request if changes occurred
|
||||
id: pr
|
||||
if: steps.merge_queue.outputs.in_queue != 'true'
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
@@ -417,6 +298,7 @@ jobs:
|
||||
- Crowdin source string uploads
|
||||
- Crowdin translation downloads
|
||||
- Obtainium deep links and import files
|
||||
- Schema strings regenerated from the protobufs pin
|
||||
title: 'chore: Scheduled updates (Firmware, Hardware, Translations)'
|
||||
body: ${{ steps.pr_body.outputs.content }}
|
||||
branch: 'scheduled-updates'
|
||||
@@ -431,11 +313,33 @@ jobs:
|
||||
androidApp/src/main/assets/maintenance_uf2.json
|
||||
fastlane/metadata/android/**
|
||||
**/strings.xml
|
||||
**/schema_strings.xml
|
||||
.skills/compose-ui/strings-index.txt
|
||||
docs/**/*.md
|
||||
README.md
|
||||
obtainium/**
|
||||
labels: |
|
||||
automation
|
||||
l10n
|
||||
firmware
|
||||
hardware
|
||||
labels: ${{ steps.labels.outputs.labels }}
|
||||
|
||||
# "Merge when ready": the merge queue takes the PR once its checks pass.
|
||||
# No merge method is passed; the queue sets it. Re-run on every update,
|
||||
# since a fresh push can clear a previous request. GitHub refuses the
|
||||
# request on a PR that is already mergeable, so that case merges directly.
|
||||
# A failure fails the run: the PR exists either way, and a request that
|
||||
# cannot be made is something to fix, not to hide.
|
||||
- name: Enable auto-merge
|
||||
if: ${{ steps.merge_queue.outputs.in_queue != 'true' && (steps.pr.outputs.pull-request-operation == 'created' || steps.pr.outputs.pull-request-operation == 'updated') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pull-request-number }}
|
||||
run: |
|
||||
PR_ID=$(gh pr view "$PR_NUMBER" --json id --jq .id)
|
||||
QUERY=$(cat <<'GQL'
|
||||
mutation($id: ID!) {
|
||||
enablePullRequestAutoMerge(input: { pullRequestId: $id }) {
|
||||
pullRequest { number autoMergeRequest { enabledAt } }
|
||||
}
|
||||
}
|
||||
GQL
|
||||
)
|
||||
gh api graphql -f query="$QUERY" -F id="$PR_ID" \
|
||||
|| gh pr merge "$PR_NUMBER"
|
||||
@@ -0,0 +1,197 @@
|
||||
name: Store Screenshots
|
||||
|
||||
# Captures the store-listing screenshots from the real debug apps, connected to Demo
|
||||
# Mode's hidden showcase mesh. Android runs `:store-screenshots` once per flavor, each on
|
||||
# its own emulator: the google flavor feeds the Play listing, the fdroid flavor the fastlane
|
||||
# tree F-Droid and IzzyOnDroid read, each uploaded as `store-screenshots-<flavor>` laid
|
||||
# out as `images/<type>Screenshots/<n>_<name>.png`. Desktop runs the real app on a virtual
|
||||
# display (`store-screenshots/capture-desktop.sh`) and uploads the five Flathub PNGs as
|
||||
# `store-screenshots-desktop`.
|
||||
#
|
||||
# release.yml calls this on every internal cut. It also runs on a pull request that
|
||||
# touches the renderer or the showcase mesh, so a change is seen on an emulator
|
||||
# before it reaches a release.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'The ref to capture (the release tag)'
|
||||
required: true
|
||||
type: string
|
||||
soft:
|
||||
# A caller cannot mark a reusable workflow allowed-to-fail, and a failed job in
|
||||
# release.yml deletes the internal tag. With soft, a failed capture fails nothing.
|
||||
description: 'Let a failed capture pass without failing the calling run'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'store-screenshots/**'
|
||||
- 'core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockScenario.kt'
|
||||
- '.github/workflows/store-screenshots.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A called workflow's github.workflow is the caller's name, so a prefix built from it
|
||||
# equals release.yml's own group on the same tag and GitHub cancels the call as a deadlock.
|
||||
concurrency:
|
||||
group: store-screenshots-${{ github.event.pull_request.number || inputs.ref || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
android:
|
||||
name: Android (${{ matrix.flavor }})
|
||||
# Hosted x64 runners expose KVM; the emulator needs it.
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 35
|
||||
continue-on-error: ${{ inputs.soft == true }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- flavor: google
|
||||
variant: Google
|
||||
- flavor: fdroid
|
||||
variant: Fdroid
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
cache_read_only: 'true'
|
||||
|
||||
# The google flavor draws Google Maps, which needs the debug Maps key; the fdroid
|
||||
# flavor draws MapLibre and needs nothing.
|
||||
- name: Provide the debug Maps key
|
||||
if: ${{ matrix.flavor == 'google' }}
|
||||
env:
|
||||
GOOGLE_MAPS_API_KEY_DEBUG: ${{ secrets.GOOGLE_MAPS_API_KEY_DEBUG }}
|
||||
run: |
|
||||
if [ -n "$GOOGLE_MAPS_API_KEY_DEBUG" ]; then
|
||||
echo "MAPS_API_KEY=$GOOGLE_MAPS_API_KEY_DEBUG" >> ./google.properties
|
||||
else
|
||||
echo "::warning::GOOGLE_MAPS_API_KEY_DEBUG is unset -- the google-flavor map shot will be blank."
|
||||
fi
|
||||
|
||||
# Built before the emulator boots, so the emulator step only installs and runs, with
|
||||
# no second Gradle configuration.
|
||||
- name: Build the app and the capture module
|
||||
run: ./gradlew :androidApp:assemble${{ matrix.variant }}Debug :store-screenshots:assemble${{ matrix.variant }}Debug
|
||||
|
||||
- name: Enable KVM (for the emulator)
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
# The runner's `script:` runs each line in its own shell, so the capture is one script.
|
||||
- name: Capture on the emulator
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 34
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
cores: 4
|
||||
disable-animations: true
|
||||
emulator-options: -no-window -gpu swiftshader -noaudio -no-boot-anim -camera-back none
|
||||
script: store-screenshots/capture-android.sh ${{ matrix.flavor }} out
|
||||
|
||||
- name: Summarize the captures
|
||||
if: ${{ always() }}
|
||||
env:
|
||||
FLAVOR: ${{ matrix.flavor }}
|
||||
run: |
|
||||
{
|
||||
echo "### Store screenshots (${FLAVOR})"
|
||||
if [ -n "$(find "out/$FLAVOR" -name '*.png' 2>/dev/null)" ]; then
|
||||
find "out/$FLAVOR" -name '*.png' | sort | sed "s|^out/$FLAVOR/|- |"
|
||||
else
|
||||
echo "- none captured"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload the captures
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: store-screenshots-${{ matrix.flavor }}
|
||||
path: out/${{ matrix.flavor }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
# The failing test's exception is in instrument.txt, the rest of the story in logcat.txt.
|
||||
- name: Upload the test results
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: store-screenshots-test-results-${{ matrix.flavor }}
|
||||
path: out/test-results/${{ matrix.flavor }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
# The five Flathub shots, from the real desktop debug build on a virtual display.
|
||||
desktop:
|
||||
name: Desktop
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
continue-on-error: ${{ inputs.soft == true }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Gradle Setup
|
||||
uses: ./.github/actions/gradle-setup
|
||||
with:
|
||||
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
|
||||
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
cache_read_only: 'true'
|
||||
|
||||
- name: Build the desktop app
|
||||
run: ./gradlew :desktopApp:createDistributable
|
||||
|
||||
# Software GL through zink over lavapipe, because Skiko refuses llvmpipe by name.
|
||||
- name: Install a virtual display and software GL
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq xvfb openbox xdotool imagemagick \
|
||||
libvulkan1 mesa-vulkan-drivers libgl1 libglx-mesa0 libegl1 libgl1-mesa-dri
|
||||
|
||||
- name: Capture on a virtual display
|
||||
run: |
|
||||
store-screenshots/capture-desktop.sh \
|
||||
"desktopApp/build/compose/binaries/main/app/Meshtastic Desktop/bin/Meshtastic Desktop" \
|
||||
out/desktop
|
||||
|
||||
- name: Summarize the captures
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
{
|
||||
echo "### Store screenshots (desktop)"
|
||||
find out/desktop -name '*.png' 2>/dev/null | sort | sed 's|^out/desktop/|- |' || echo "- none captured"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload the captures
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: store-screenshots-desktop
|
||||
path: out/desktop
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -1,14 +1,14 @@
|
||||
name: Update Changelog
|
||||
|
||||
# Manual only: promote.yml stamps the released section at release time on its
|
||||
# own. Dispatch this to refresh the [Unreleased] section between releases.
|
||||
# Create or Promote Release dispatches this after every internal, closed and open run;
|
||||
# a production promotion stamps the released section itself. Dispatch it by hand to
|
||||
# refresh the [Unreleased] section at any other time.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# The PR is opened and queued with CROWDIN_GITHUB_TOKEN through .github/actions/bot-pr.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
concurrency:
|
||||
group: changelog-${{ github.ref }}
|
||||
@@ -266,41 +266,13 @@ jobs:
|
||||
|
||||
- name: Create or update changelog PR
|
||||
if: steps.tags.outputs.prod != '' && steps.update.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
BRANCH="automation/update-changelog"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Force-update the automation branch
|
||||
git checkout -B "$BRANCH"
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs: update CHANGELOG.md"
|
||||
git push origin "$BRANCH" --force
|
||||
|
||||
# Create or update the PR
|
||||
EXISTING_PR=$(gh pr list --head "$BRANCH" --state open --json number -q '.[0].number')
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "Updated existing PR #$EXISTING_PR"
|
||||
else
|
||||
gh pr create \
|
||||
--title "docs: update CHANGELOG.md" \
|
||||
--body "Automated changelog refresh, dispatched from main." \
|
||||
--head "$BRANCH" \
|
||||
--base main \
|
||||
--label "automation" \
|
||||
--label "skip-changelog"
|
||||
echo "Created new changelog PR"
|
||||
fi
|
||||
|
||||
# Post the required "Check Workflow Status" commit status so the PR
|
||||
# isn't blocked. PRs from GITHUB_TOKEN don't trigger pull_request
|
||||
# workflows, so the normal CI never runs. CHANGELOG-only PRs don't
|
||||
# need CI checks.
|
||||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
gh api "repos/${{ github.repository }}/statuses/${COMMIT_SHA}" \
|
||||
-f state="success" \
|
||||
-f context="Check Workflow Status" \
|
||||
-f description="Skipped — changelog-only PR"
|
||||
uses: ./.github/actions/bot-pr
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
branch: automation/update-changelog
|
||||
title: 'docs: update CHANGELOG.md'
|
||||
body: 'Automated changelog refresh, dispatched from main.'
|
||||
add-paths: CHANGELOG.md
|
||||
labels: |
|
||||
automation
|
||||
skip-changelog
|
||||
@@ -8,29 +8,17 @@ on:
|
||||
paths:
|
||||
- 'scripts/verify-flatpak/**'
|
||||
- '.github/workflows/verify-flatpak.yml'
|
||||
# The dependency surface the manifest captures is verified post-merge. This is not a
|
||||
# required check and never ran in the merge queue, so per-PR it cost ~2 runner slots
|
||||
# for a signal that blocks nothing; post-merge still catches a break within one merge,
|
||||
# and `main` itself was previously never verified at all.
|
||||
# Post-merge only for what the check itself depends on. The offline manifest pins the
|
||||
# Gradle distribution apart from the wrapper, so a wrapper bump is checked on merge.
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'scripts/verify-flatpak/**'
|
||||
- '.github/workflows/verify-flatpak.yml'
|
||||
- 'build.gradle.kts'
|
||||
- 'settings.gradle.kts'
|
||||
# The desktop module's build config shapes the uber jar the flatpak wraps.
|
||||
- 'desktopApp/**'
|
||||
# The offline manifest pins the Gradle distribution independently of the wrapper —
|
||||
# a wrapper bump without a manifest update breaks the offline build silently.
|
||||
- 'gradle/wrapper/**'
|
||||
# build.gradle.kts reads compose-multiplatform from the catalog (#6911), so a
|
||||
# catalog-only bump changes the manifest's platform URLs. Deliberately the whole
|
||||
# file and not a key filter: a filter naming today's keys goes stale silently the
|
||||
# moment the manifest reads another one — the failure #6911 existed to remove.
|
||||
- 'gradle/libs.versions.toml'
|
||||
# Drift no path filter can see: a Flathub runtime bump, or an upstream artifact that
|
||||
# moved or vanished. Nothing in this repo changes, so no other trigger would fire.
|
||||
# The dependency surface the manifest captures (desktopApp/**, the version catalog, the
|
||||
# root build scripts) is verified nightly, as is drift no path filter can see: a
|
||||
# Flathub runtime bump, or an upstream artifact that moved or vanished.
|
||||
schedule:
|
||||
- cron: '0 4 * * *'
|
||||
workflow_dispatch:
|
||||
@@ -60,8 +48,6 @@ jobs:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# Renovate mirrors wrapper bumps into the manifest's distribution URL but cannot
|
||||
# rewrite its sha256, so on a wrapper bump this drift is expected: fail here in
|
||||
@@ -106,7 +92,7 @@ jobs:
|
||||
if: steps.setup-jbr.outcome == 'failure'
|
||||
run: echo "::warning::JBR setup-java failed; falling back to Foojay toolchain provisioning."
|
||||
|
||||
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
|
||||
- uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6
|
||||
with:
|
||||
develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
|
||||
|
||||
@@ -121,9 +107,11 @@ jobs:
|
||||
echo "### Flatpak Sources Summary (${{ matrix.arch }})" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- URLs captured: $(jq length flatpak-sources.json)" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# flatpak-builder re-downloads the whole closure anyway; a second copy just eats disk.
|
||||
- name: Reclaim the isolated Gradle home
|
||||
run: rm -rf "$RUNNER_TEMP/flatpak-gradle-home"
|
||||
# The sandbox build below starts its own Gradle and Kotlin daemons. The pair
|
||||
# this step left running holds the other half of the runner's memory, and the
|
||||
# VM stalls and is shut down mid-compile. Stop them; the home itself can stay.
|
||||
- name: Stop the outer Gradle daemon
|
||||
run: ./gradlew -Dgradle.user.home="$RUNNER_TEMP/flatpak-gradle-home" --stop
|
||||
|
||||
- name: Clone the Flathub packaging repo
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: Bump Version Name
|
||||
|
||||
# Opens the next patch line once a version ships to production, through a PR that
|
||||
# merges itself: VERSION_NAME_BASE, its AppStream <release> entry and the Play
|
||||
# what's-new, all written by scripts/bump-version-name.py.
|
||||
#
|
||||
# promote.yml flips the release to production with GITHUB_TOKEN, whose events start no
|
||||
# workflows, so it dispatches this one explicitly, as it does winget-publish.yml. The
|
||||
# release trigger covers a release published by hand; workflow_dispatch is the retry path.
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Production release tag that shipped (e.g., v2.8.2)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# The PR is opened and queued with CROWDIN_GITHUB_TOKEN through .github/actions/bot-pr.
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
# A release event overlapping a dispatch would open two PRs for the same version.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && !github.event.release.prerelease && !github.event.release.draft }}
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
steps:
|
||||
# A release event checks out the tag by default; the bump branches from main.
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Resolve next version
|
||||
id: next
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "::error::'$TAG' is not a production tag (vX.Y.Z)."
|
||||
exit 1
|
||||
fi
|
||||
SHIPPED="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
|
||||
NEXT="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
|
||||
BRANCH="automation/version-name-${NEXT}"
|
||||
CURRENT=$(sed -n 's/^VERSION_NAME_BASE=//p' config.properties)
|
||||
|
||||
# A hand bump that already landed (a minor line, say) wins over the patch default.
|
||||
if [[ "$CURRENT" != "$SHIPPED" && "$(printf '%s\n%s\n' "$CURRENT" "$SHIPPED" | sort -V | tail -1)" == "$CURRENT" ]]; then
|
||||
echo "::notice::main is already on $CURRENT, past $SHIPPED; nothing to bump."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
OPEN=$(gh pr list --state open --limit 200 --json number,title,headRefName \
|
||||
| jq -r --arg b "$BRANCH" --arg n "$NEXT" \
|
||||
'.[] | select(.headRefName != $b and (.title | test("VERSION_NAME_BASE to " + ($n | gsub("[.]"; "[.]")) + "([^0-9.]|$)"))) | .number')
|
||||
if [[ -n "$OPEN" ]]; then
|
||||
echo "::notice::A hand bump to $NEXT is already open: #$OPEN."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
{
|
||||
echo "skip=false"
|
||||
echo "shipped=$SHIPPED"
|
||||
echo "next=$NEXT"
|
||||
echo "branch=$BRANCH"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Bump
|
||||
if: ${{ steps.next.outputs.skip == 'false' }}
|
||||
env:
|
||||
NEXT: ${{ steps.next.outputs.next }}
|
||||
run: python3 scripts/bump-version-name.py "$NEXT"
|
||||
|
||||
# Creates the PR, or updates it on a retry. Pushed with the PAT, so pull-request.yml's
|
||||
# AppStream and what's-new gates run on it like any other bump.
|
||||
- name: Open the bump PR
|
||||
if: ${{ steps.next.outputs.skip == 'false' }}
|
||||
uses: ./.github/actions/bot-pr
|
||||
with:
|
||||
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
|
||||
title: 'chore: bump VERSION_NAME_BASE to ${{ steps.next.outputs.next }}'
|
||||
body: |
|
||||
Opens the ${{ steps.next.outputs.next }} line now that v${{ steps.next.outputs.shipped }} has shipped.
|
||||
|
||||
The metainfo `<description>` for ${{ steps.next.outputs.next }} is a placeholder. Rewrite it and re-run `python3 scripts/sync-play-changelog.py` before the ${{ steps.next.outputs.next }} internal cut, or it ships as the release Highlights and the Play what's-new.
|
||||
branch: ${{ steps.next.outputs.branch }}
|
||||
add-paths: |
|
||||
config.properties
|
||||
desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml
|
||||
fastlane/metadata/android/en-US/changelogs/default.txt
|
||||
labels: |
|
||||
automation
|
||||
skip-changelog
|
||||
@@ -43,6 +43,13 @@ jobs:
|
||||
# the token is configured.
|
||||
HAS_WINGET_TOKEN: ${{ secrets.WINGET_TOKEN != '' && 'true' || 'false' }}
|
||||
steps:
|
||||
# A skipped submission must not read as a published one.
|
||||
- name: Report an unconfigured winget publish
|
||||
if: env.HAS_WINGET_TOKEN != 'true'
|
||||
run: |
|
||||
echo "::warning::WINGET_TOKEN is not set; nothing was submitted to microsoft/winget-pkgs."
|
||||
echo "- winget: skipped, WINGET_TOKEN not set" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Prerequisites (both manual, one-time):
|
||||
# 1. Meshtastic.MeshtasticDesktop must already exist in
|
||||
# microsoft/winget-pkgs — winget-releaser only updates existing
|
||||
|
||||
+4
-3
@@ -15,6 +15,7 @@
|
||||
.cxx
|
||||
/app/release
|
||||
**/debug/**
|
||||
!/androidApp/src/debug/**
|
||||
**/release/**
|
||||
|
||||
# Java KeyStore certificates
|
||||
@@ -53,7 +54,6 @@ wireless-install.sh
|
||||
docs/_site/
|
||||
docs/.jekyll-cache/
|
||||
docs/.jekyll-metadata
|
||||
docs/Gemfile.lock
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
@@ -70,6 +70,9 @@ hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
.attach_pid*
|
||||
|
||||
# Local build output redirected to a file
|
||||
/build.log
|
||||
|
||||
# Local library development clones
|
||||
/coil/
|
||||
/kable/
|
||||
@@ -82,8 +85,6 @@ feature/docs/src/commonMain/composeResources/files/*/docs/
|
||||
/desktop/bin/
|
||||
/build-logic/convention/bin/
|
||||
/.specify/extensions/.cache/
|
||||
# Jekyll local config (comments out remote_theme for local builds)
|
||||
docs/_config_local.yml
|
||||
|
||||
# Flatpak source manifests and repositories
|
||||
flatpak-sources-*.json
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: ci-cost-control
|
||||
description: Keep GitHub Actions spend down on Meshtastic-Android - avoid redundant runs, cancel superseded ones, and pick the narrowest CI path for the change. Use this whenever you are about to push repeatedly, re-run a workflow, touch `.github/workflows/`, or someone asks why CI is slow, queued or expensive.
|
||||
---
|
||||
|
||||
# Skill: CI Cost Control & Monitoring
|
||||
|
||||
## Description
|
||||
@@ -15,7 +20,7 @@ gh run list --branch $(git branch --show-current) --limit 5
|
||||
|
||||
### 2. Local First
|
||||
NEVER use CI as a "remote compiler."
|
||||
- You must run `./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests` locally before pushing.
|
||||
- You must run `./gradlew spotlessApply spotlessCheck detekt detektTypeResolved assembleDebug test allTests` locally before pushing.
|
||||
- If local tests fail, CI **will** fail. Do not waste the tokens or the compute.
|
||||
|
||||
### 3. Let the path filters do their job
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: code-review
|
||||
description: Review a Meshtastic-Android change against KMP architecture, Compose Multiplatform and Modern Android Development conventions, starting from the four defect classes that keep recurring because neither the compiler, detekt nor spotless can see them. Use this for any PR review, for self-review before pushing, and whenever asked whether a change is safe to merge.
|
||||
---
|
||||
|
||||
# Skill: Code Review
|
||||
|
||||
## Description
|
||||
@@ -58,7 +63,7 @@ When reviewing code, meticulously verify the following categories. Flag any devi
|
||||
- `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`).
|
||||
- [ ] **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`). `detektTypeResolved` enforces the catch side through `SuspendFunSwallowedCancellation`: a generic catch around a suspend call needs `catch (e: CancellationException) { throw e }` before it, or `if (e is CancellationException) currentCoroutineContext().ensureActive()` as its first line where a stray cancellation must not end the caller. A konsist rule enforces the import.
|
||||
- [ ] **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.
|
||||
@@ -68,7 +73,7 @@ When reviewing code, meticulously verify the following categories. Flag any devi
|
||||
- [ ] **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 desktopApp/JVM features. No inline placeholders in feature modules.
|
||||
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktopApp/tablet breakpoints (≥ 1200dp).
|
||||
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfoV2()`, which includes the desktopApp/tablet Large and XL width classes (≥ 1200dp); `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` is deprecated in its favour.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
---
|
||||
name: compose-ui
|
||||
description: Build shared Compose Multiplatform UI in Meshtastic-Android - adaptive layouts on Material 3 Adaptive, plus the string and resource rules. Use this whenever you add or change a composable, add a user-facing string, or work on tablet, desktop or landscape layout. Consult the bundled `strings-index.txt` rather than opening the raw `strings.xml`, which is guarded for size.
|
||||
---
|
||||
|
||||
# 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 desktopApp/tablets.
|
||||
- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfoV2()`, which includes the Large (1200dp) and XL (1600dp) width classes; `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` is deprecated in its favour. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktopApp/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 desktopApp/JVM features.
|
||||
- **Empty states:** Use `EmptyState(icon, title, supportingText, action)` from `core:ui/commonMain` for an empty list or pane rather than a hand-built icon-and-text column.
|
||||
- **Theme Picker:** Use `ThemePickerDialog` from `feature:settings/commonMain`.
|
||||
- **Platform Implementations:** Inject platform-specific behavior (e.g., Map providers) via `CompositionLocal` from the `androidApp` or `desktopApp` shells. Do not tightly couple Google Maps dependencies to `commonMain`; the MapLibre surfaces live in `:feature:map-maplibre`, not in a `core` module.
|
||||
|
||||
@@ -44,6 +50,15 @@ Choose the right tool for the job:
|
||||
2. Run `python3 scripts/sort-strings.py` — keeps the file sorted and regenerates `strings-index.txt`.
|
||||
3. Use the generated `org.meshtastic.core.resources.<key>` symbol.
|
||||
4. Validate UI presentation.
|
||||
- **Schema strings are generated, not written.** Every label and description in the protobufs field metadata is
|
||||
in `values/schema_strings.xml`, keyed by schema path: `Config.LoRaConfig.hop_limit` is
|
||||
`Res.string.schema_lora_hop_limit`, its summary `schema_lora_hop_limit_description`, the enum value
|
||||
`PositionFlags.DOP` `schema_position_positionflags_dop` (all indexed under `### SCHEMA` in `strings-index.txt`).
|
||||
A control that edits one whole schema field uses that key; a control that edits a bit, a threshold, a negation or
|
||||
drops a unit keeps a hand-written string. Never edit the generated file or write a `schema_` key by hand;
|
||||
`:schema-strings:test` fails on both. Wrong wording is a `protobufs` change. A merged protobufs pin bump
|
||||
triggers a `scheduled-updates` run on main that regenerates the file (it records the pin it was built from); run
|
||||
`./gradlew :schema-strings:sync` yourself only when you need a new key before that PR lands.
|
||||
|
||||
## 3. Tooling & Capabilities
|
||||
- **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `androidApp` and `desktopApp` host modules.
|
||||
|
||||
Generated
+611
-240
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: design-standards
|
||||
description: Apply the Meshtastic design standards on Android - brand colours, Material 3 tokens, MeshtasticIcons and accessibility - with the upstream `meshtastic/design` standards as the source of truth. Use this whenever you choose a colour, icon, spacing value or contrast level, and before claiming a screen matches the design.
|
||||
---
|
||||
|
||||
# Skill: Meshtastic Design Standards
|
||||
|
||||
## Description
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: implement-feature
|
||||
description: The end-to-end workflow for adding a feature to Meshtastic-Android, in the order that keeps KMP source sets and the architecture intact. Use this whenever you start implementing a new feature or a sizeable behaviour change, before writing any code.
|
||||
---
|
||||
|
||||
# Skill: Implement a Feature
|
||||
|
||||
## Description
|
||||
@@ -33,7 +38,7 @@ A step-by-step workflow for implementing a new feature in the Meshtastic-Android
|
||||
### 6. Verify Locally
|
||||
- Run the baseline checks (see `testing-ci` skill):
|
||||
```bash
|
||||
./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests
|
||||
./gradlew spotlessApply spotlessCheck detekt detektTypeResolved assembleDebug test allTests
|
||||
```
|
||||
- Add `kmpSmokeCompile` to the command when the feature touches a KMP module (most features do) so cross-target compile failures surface before merge.
|
||||
- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `androidApp/proguard-rules.pro` and `desktopApp/proguard-rules.pro`, then verify release builds:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: kmp-architecture
|
||||
description: Kotlin Multiplatform source-set rules for Meshtastic-Android - where commonMain ends, how expect/actual is bridged, and the networking, database and platform-integration boundaries. Use this whenever you add a file to a KMP module, move code between source sets, or hit a compile error that appears on only one target.
|
||||
---
|
||||
|
||||
# Skill: KMP Architecture & Source-Set Bridging
|
||||
|
||||
## Description
|
||||
@@ -6,7 +11,8 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract
|
||||
## 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.
|
||||
- **`jvmMain`:** Desktop-only JVM code.
|
||||
- **`jvmAndroidMain`:** JVM code shared between Android and Desktop. Uses the `meshtastic.kmp.jvm.android` convention plugin to bridge `jvm` and `android` source sets without manual `dependsOn` hacks.
|
||||
- **`androidApp` / `desktopApp`:** Host shells. Responsible for Koin DI root wiring (`MainKoinModule`/`AppKoinModule`, `DesktopKoinModule`), host-level UI themes, and running the `MeshtasticNavDisplay`.
|
||||
|
||||
## 2. Bridging Strategies
|
||||
@@ -49,7 +55,7 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract
|
||||
- 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.).
|
||||
1. Ensure all new logic compiles against the KMP core targets (`jvm()`, `iosSimulatorArm64()`).
|
||||
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 `desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` and use `NoopStubs.kt` to temporarily mock missing platform implementations.
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
---
|
||||
name: navigation-and-di
|
||||
description: Koin Annotations dependency injection and JetBrains Navigation 3 in Meshtastic-Android, including the anti-patterns that compile cleanly and then fail at runtime. Use this whenever you add a screen, a route, a ViewModel or a Koin module, or when navigation or injection behaves unexpectedly.
|
||||
---
|
||||
|
||||
# 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.
|
||||
This skill covers dependency injection (Koin Annotations 4.2.x) and Navigation 3 1.2 (the JetBrains `navigation3-ui` mirror over AndroidX `navigation3-runtime`) architecture, constraints, and anti-patterns within the Meshtastic-Android KMP codebase.
|
||||
|
||||
## Dependency Injection (Koin)
|
||||
|
||||
@@ -41,14 +46,17 @@ startKoin<AndroidKoinApp> {
|
||||
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.
|
||||
4. **Host Integration:** Use `MeshtasticNavDisplay` (from `core:ui/commonMain`) as the Navigation 3 host. It owns the entry decorators; do not create them in app hosts or feature modules.
|
||||
5. **Scenes:** `MeshtasticNavDisplay` renders `ListDetailSceneStrategy` scenes (`listPane()`, `detailPane()`, `extraPane()` entry metadata) and falls back to a single pane. It registers no dialog or supporting-pane strategy, so that metadata has no effect.
|
||||
6. **Back Handlers:** Use `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures in multiplatform code. Do not use Android's `BackHandler`.
|
||||
7. **Deep Links:** Use `DeepLinkRouter.route()` in `core:navigation` to synthesize typed backstacks from RESTful paths.
|
||||
8. **Tab Lifetime:** A hidden tab's entry ViewModels and saved state live until that entry is popped from its own stack; switching tabs does not clear them.
|
||||
|
||||
### 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.
|
||||
- **Decorator Reuse Across Tabs:** Do **not** decorate several back stacks with one `NavEntryDecorator` set. Navigation 3 pops every entry missing from the stack it is given, so a shared saveable-state or ViewModel-store decorator clears the tab you just left. The `MultiBackstack` overload of `MeshtasticNavDisplay` gives every tab's stack its own saveable-state and ViewModel-store decorators through `rememberDecoratedNavEntries`, following the per-stack decorators of the Navigation 3 multiple back stacks recipe, and passes only the active tab's entries to `NavDisplay`. Its `entryProvider` must therefore resolve every tab's keys, not only the active tab's.
|
||||
- **Custom Backstack Mutation:** Do **not** mutate back navigation with custom stacks disconnected from the app backstack. Mutate `NavBackStack<NavKey>` directly with `add(...)` and `removeLastOrNull()`.
|
||||
- **Inline Entries in Nav Tests:** Do **not** write a test's entries inline in a composable host. `entryProvider` and `entry<K>` are `inline`, so inline entries become remembered lambdas the compiler updates in place and a stale back-stack capture passes unseen. Declare them in a plain `EntryProviderScope<NavKey>` extension, as feature graphs do.
|
||||
|
||||
## Reference Anchors
|
||||
- **App Startup / Koin Bootstrap:** `androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: new-branch
|
||||
description: The canonical recipe for starting a fresh working branch off a freshly fetched `origin/main` in Meshtastic-Android. Use this whenever a unit of work starts - "new branch off main", "peel off a fresh branch", "dust off #NNNN" - so the branch is based correctly and nothing is carried over from the last one.
|
||||
---
|
||||
|
||||
# Skill: New Branch Bootstrap
|
||||
|
||||
## Description
|
||||
@@ -66,6 +71,7 @@ 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 branch --show-current # confirm this is the PR you meant, before any push
|
||||
git rebase upstream/main
|
||||
# Resolve conflicts, then:
|
||||
git push --force-with-lease
|
||||
@@ -73,6 +79,11 @@ git push --force-with-lease
|
||||
|
||||
Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes.
|
||||
|
||||
Read that branch name before you push. A mistyped `<NNNN>` is a valid PR number
|
||||
belonging to someone else, and `--force-with-lease` will not save you: it only
|
||||
refuses when the remote ref moved after you fetched it, which is exactly not the
|
||||
case here. The lease is intact and the wrong branch gets rewritten.
|
||||
|
||||
## Post-Branch Checklist
|
||||
- [ ] Branch name carries a conventional prefix, or is a spec-driven name (numeric or `YYYYMMDD-HHMMSS-`) per Branch Naming above.
|
||||
- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap).
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
---
|
||||
name: project-overview
|
||||
description: The Meshtastic-Android codebase map - module directory, namespacing conventions, JDK and SDK requirements, and the mandatory bootstrap steps. Read this first in any session that will build, test or navigate the repo: the bootstrap here has to run before any Gradle task, and skipping it is what makes the first build fail.
|
||||
---
|
||||
|
||||
# Skill: Project Overview & Codebase Map
|
||||
|
||||
## Description
|
||||
Module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android.
|
||||
|
||||
- **Build System:** Gradle (Kotlin DSL). JDK 25 REQUIRED. Target SDK: API 36. Min SDK: API 26.
|
||||
- **Build System:** Gradle (Kotlin DSL). JDK 25 REQUIRED. Target SDK: API 37. Min SDK: API 26.
|
||||
- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics)
|
||||
- **Android-only Modules:** `core:barcode` (CameraX), `feature:widget` (Glance home-screen widget), and `baselineprofile` (Macrobenchmark). Shared contracts are abstracted into `core:ui/commonMain`.
|
||||
- **Android-only Modules:** `core:barcode` (CameraX), `core:nfc` (NFC hardware), `feature:widget` (Glance home-screen widget), and `baselineprofile` (Macrobenchmark). Shared contracts are abstracted into `core:ui/commonMain`.
|
||||
|
||||
## Codebase Map
|
||||
|
||||
@@ -22,7 +27,7 @@ Module directory, namespacing conventions, environment setup, and troubleshootin
|
||||
| `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:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioTransport`). |
|
||||
| `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. |
|
||||
@@ -30,11 +35,11 @@ Module directory, namespacing conventions, environment setup, and troubleshootin
|
||||
| `core:takserver` | Meshtastic ↔ TAK (ATAK/iTAK) bridge — local CoT server and CoT ⇄ mesh conversion. |
|
||||
| `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:nfc` | Android-only NFC hardware implementation (an Android library, not KMP). The shared NFC abstractions are the `LocalNfc*Provider` composition locals in `core:ui`. |
|
||||
| `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`, `discovery`, `docs`, `widget`). Most are KMP and use the `meshtastic.kmp.feature` convention plugin; `widget` (Glance) is Android-only. |
|
||||
| `feature/` | Feature modules (e.g., `settings`, `map`, `map-maplibre`, `map-terrain`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `discovery`, `docs`, `widget`). Most are KMP and use the `meshtastic.kmp.feature` convention plugin; `widget` (Glance) is Android-only. |
|
||||
| `baselineprofile/` | Macrobenchmark Baseline Profile generation for `:androidApp` (AOT-compiled cold-start journey). Android-only. |
|
||||
| `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. |
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: speckit
|
||||
description: The Spec Kit specification-driven workflow in Meshtastic-Android - how a feature description becomes a spec, a plan, tasks and an implementation, and what the constitution requires at each step. Use this when asked to write or update a spec, plan or tasks file, or when working anywhere under `specs/`.
|
||||
---
|
||||
|
||||
# Skill: Spec Kit (Specification-Driven Development)
|
||||
|
||||
## Description
|
||||
@@ -108,14 +113,14 @@ specs/
|
||||
The project constitution at `.specify/memory/constitution.md` defines non-negotiable principles.
|
||||
All specs, plans, and tasks are validated against it during `/speckit.analyze`.
|
||||
|
||||
Current constitution (v1.4.0) enforces 7 principles:
|
||||
Current constitution (v1.4.2) enforces 7 principles:
|
||||
|
||||
1. **KMP Core** — Business logic in `commonMain` only
|
||||
2. **Zero Lint Tolerance** — `spotlessCheck` + `detekt` must pass
|
||||
3. **Compose Multiplatform UI** — CMP, not Android-only Compose
|
||||
4. **Privacy First** — No PII/location/key exposure
|
||||
5. **Design Standards Compliance** — Review against Meshtastic design standards; cross-platform features must reference an upstream spec from `meshtastic/design/features/`
|
||||
6. **Documentation Freshness** — User-facing changes update `docs/en/` (in-app browser, Jekyll, Docusaurus) with `last_updated` frontmatter; links, coverage, and the DocBundleLoader registry are a blocking CI gate (`docs-quality.yml`) on PRs touching `docs/en/**`, freshness advisory
|
||||
6. **Documentation Freshness** — User-facing changes update `docs/en/` (in-app browser, Jekyll, Docusaurus) with `last_updated` frontmatter; links, coverage, the DocBundleLoader registry, and locale `layout`/`nav_order` matching `docs/en/` with no locale `parent` are a blocking CI gate (`docs-quality.yml`) on PRs touching `docs/**/*.md`, freshness advisory
|
||||
7. **Verify Before Push** — Local verification before any `git push`
|
||||
|
||||
## Extension Hooks
|
||||
|
||||
+43
-28
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: testing-ci
|
||||
description: Decide what to run locally before pushing Meshtastic-Android, and read the CI pipeline. Use this whenever you finish a change and need the right verification for its change type, when a CI job fails or is skipped, or when you need to know why the merge queue behaved the way it did.
|
||||
---
|
||||
|
||||
# Skill: Testing and CI Verification
|
||||
|
||||
## Description
|
||||
@@ -8,9 +13,13 @@ Guidelines and commands for verifying code changes locally and understanding the
|
||||
Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation:
|
||||
|
||||
```bash
|
||||
./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests
|
||||
./gradlew spotlessApply spotlessCheck detekt detektTypeResolved assembleDebug test allTests
|
||||
```
|
||||
|
||||
`detekt` alone has no classpath, so it skips every rule that needs type resolution (`UnsafeCallOnNullableType`, `SuspendFunSwallowedCancellation`, `UnsafeCast` and the rest). `detektTypeResolved` runs those against each module's production JVM and Android debug compilations; it shares the module's `detekt-baseline.xml`.
|
||||
|
||||
Regenerating that baseline needs care, because detekt's baseline tasks rewrite `CurrentIssues` from their own run and keep only `ManuallySuppressedIssues`. A plain `detektBaseline` therefore drops every type-resolved ID, and `detektBaselineMain<Target>` or `detektBaseline<Variant>` writes `detekt-baseline-<compilation>.xml`, which no check reads. After a plain `detektBaseline`, run the type-resolved baseline tasks for that module (`detektBaselineMainJvm`, `detektBaselineMainAndroid`, `detektBaselineFdroidDebug` and so on), copy the IDs you mean to keep from the generated files into `detekt-baseline.xml`, delete the generated files, and confirm with `detekt detektTypeResolved`. `detektBaselineMainJvm` and `detektBaselineMainAndroid` both write `detekt-baseline-main.xml`, so run and copy them one at a time.
|
||||
|
||||
> **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`:**
|
||||
@@ -52,6 +61,7 @@ When testing long-lived coroutines (e.g., `Flow.collect` loops launched in `back
|
||||
- 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`.
|
||||
- `build-logic/` changes: also `:build-logic:convention:spotlessCheck :build-logic:convention:detekt`, which the root `spotlessCheck detekt` do not reach.
|
||||
|
||||
## 3) Flavor checks
|
||||
|
||||
@@ -79,65 +89,70 @@ Rendering is **host-deterministic** (layoutlib): a local `update` produces refer
|
||||
|
||||
## 3c) Fresh-install manual/agent testing: skip onboarding
|
||||
|
||||
Debug builds accept an intent extra to skip the intro flow (`MainActivity.kt`, `BuildConfig.DEBUG`-gated — never reaches release/Play builds). Pair with `pm grant` (native Android, no app code) to pre-accept runtime permissions:
|
||||
Debug builds accept an intent extra to skip the intro flow, honoured only on a launch through the `AutomationLauncher` alias (`androidApp/src/debug/AndroidManifest.xml`), which requires `DUMP` so only the shell can start it. Release builds have no alias. Pair with `pm grant` (native Android, no app code) to pre-accept runtime permissions:
|
||||
|
||||
```bash
|
||||
adb shell pm grant <pkg> android.permission.BLUETOOTH_SCAN
|
||||
adb shell pm grant <pkg> android.permission.BLUETOOTH_CONNECT
|
||||
adb shell pm grant <pkg> android.permission.ACCESS_FINE_LOCATION
|
||||
adb shell pm grant <pkg> android.permission.POST_NOTIFICATIONS # API 33+
|
||||
adb shell am start -n <pkg>/org.meshtastic.app.MainActivity --ez skip_onboarding true
|
||||
adb shell am start -n <pkg>/org.meshtastic.app.AutomationLauncher --ez skip_onboarding true
|
||||
```
|
||||
|
||||
Use this whenever driving the app from a fresh install/uninstall (screenshot tests, UI automation, agent-driven exploration) instead of clicking through the intro screens.
|
||||
|
||||
## 4) CI Pipeline Architecture
|
||||
|
||||
CI is defined in `.github/workflows/reusable-check.yml` and structured as parallel job groups:
|
||||
CI is defined in `.github/workflows/reusable-check.yml` as parallel job groups. No job `needs:` another, so every one queues for a runner as soon as the run starts:
|
||||
|
||||
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 membership is a load-balancing detail, not a taxonomy — heavy modules are moved between shards to even out wall time, so read the matrix rather than inferring it:
|
||||
- `shard-core`: `allTests` for the remaining `core:*` KMP modules.
|
||||
1. **`lint-check`** runs `spotlessCheck`, `detekt`, `detektTypeResolved` and Android lint for both flavors of `:androidApp` and `:core:barcode` in a single Gradle invocation (avoids 3x cold-start overhead), plus `:build-logic:convention:spotlessCheck` and `:build-logic:convention:detekt`, because the root tasks do not reach the included build. It checks out full history (`fetch-depth: 0`) because spotless ratchets against `origin/main`, and it has no outputs.
|
||||
2. **`test-shards`** is a 3-shard matrix that runs unit tests in parallel. Shard membership is a load-balancing detail, not a taxonomy: heavy modules are moved between shards to even out wall time, so read the matrix rather than inferring it:
|
||||
- `shard-core`: `allTests` for the remaining `core:*` KMP modules, plus `kmpSmokeCompile`.
|
||||
- `shard-feature`: `allTests` for `feature:*` KMP modules **plus `:core:service`**.
|
||||
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`androidApp`, `desktopApp`, `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`) running `:desktopApp:packageDistributionForCurrentOS :desktopApp:proguardReleaseJars` (depends on `lint-check`). It packages the debug build type — real installers the snapshot release can ship — and pulls in `proguardReleaseJars` only so a jmods-less packaging JDK fails here rather than at release time. On Linux it then wraps jpackage's `app-image` directory into a real AppImage via `scripts/build-appimage.sh`.
|
||||
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`androidApp`, `desktopApp`, `core:barcode`, `feature:widget`, `schema-strings`) **plus `:core:database` and `:core:network`**.
|
||||
Every shard uploads its test results to Codecov. Kover XML coverage is generated and uploaded only when `run_coverage` is true, which only `main-check.yml` passes. Codecov flags follow the module group (`core`, `feature`, `app`, `desktop`), not the shard.
|
||||
The validation-only jobs (`lint-check`, `screenshot-check`, `test-shards`) pin `VERSION_CODE` to one constant so the versionCode-dependent tasks keep the same cache keys on every commit; `screenshot-check` and `test-shards` also clone shallow (`fetch-depth: 1`). `android-check` and `build-desktop` check out full blob-less history so the build derives the real versionCode.
|
||||
3. **`android-check`** builds the fdroid and google debug APKs and checks their native-library ABI parity (`scripts/verify-abi-parity.sh`). The merge queue skips it. On `main` it also generates and submits the dependency graph; no other ref submits one.
|
||||
4. **`build-desktop`** is a multi-OS matrix (macOS, Windows, and Linux x64 and arm64; the job's `matrix.os` carries the labels) running `:desktopApp:packageDistributionForCurrentOS :desktopApp:proguardReleaseJars`. It packages the debug build type, real installers the snapshot release can ship, and pulls in `proguardReleaseJars` only so a jmods-less packaging JDK fails here rather than at release time. On Linux it then wraps jpackage's `app-image` directory into a real AppImage via `scripts/build-appimage.sh`.
|
||||
5. **`screenshot-check`** — Runs `:screenshot-tests:validateDebugScreenshotTest` (the visual-regression gate) and uploads a diff report. Note: `:docs-screenshots` is intentionally NOT validated here (generate-only).
|
||||
6. **`rb-check`** — Reproducible-build verification (`scripts/verify-rb.sh`). Runs **only** in the merge queue.
|
||||
7. **`verify-flatpak`** — Lives in its own workflow (`.github/workflows/verify-flatpak.yml`), **not** in `reusable-check.yml`, and is not called by it. Generates the Flatpak offline-build sources (`captureFlatpakSources`) and then builds the flatpak fully offline, on an `ubuntu-24.04` + `ubuntu-24.04-arm` matrix. Since #6919 the sources are generated inside each arch's own offline build rather than committed. It is **not a required check** and never runs in the merge queue, so its triggers are scoped accordingly: a PR runs it only when it touches the flatpak tooling itself (`scripts/verify-flatpak/**`, the workflow), while the wider dependency surface it captures (`gradle/libs.versions.toml`, `desktopApp/**`, `gradle/wrapper/**`, the root build scripts) is verified on push to `main` plus a nightly cron.
|
||||
7. **`verify-flatpak`** lives in its own workflow (`.github/workflows/verify-flatpak.yml`), **not** in `reusable-check.yml`, and is not called by it. Generates the Flatpak offline-build sources (`captureFlatpakSources`) and then builds the flatpak fully offline, on an x86_64 + aarch64 matrix of hosted Ubuntu runners. Since #6919 the sources are generated inside each arch's own offline build rather than committed. It is **not a required check** and never runs in the merge queue, so its triggers are scoped accordingly: a PR runs it only when it touches the flatpak tooling itself (`scripts/verify-flatpak/**`, the workflow), and a push to `main` runs it for those plus `gradle/wrapper/**`, because the offline manifest pins the Gradle distribution apart from the wrapper. The wider dependency surface it captures (`desktopApp/**`, `gradle/libs.versions.toml`, the root build scripts) is verified by the nightly cron.
|
||||
|
||||
8. **`protobufs-bump`** runs in its own workflow (`.github/workflows/protobufs-bump.yml`) on any PR that changes the `meshtastic-protobufs` line of `gradle/libs.versions.toml`. Comment only: it previews `:schema-strings:sync` at the new pin and leaves one sticky comment from `scripts/protobufs-bump-summary.py` with the upstream compare, the merged protobufs PRs, the `.proto` delta and the settings strings that will change. Nothing is pushed to the branch. The regeneration itself is a step of `scheduled-updates.yml`, which runs every 6 hours at :17 and on every push to `main` that changes `gradle/libs.versions.toml`. `values/schema_strings.xml` records the pin it was built from; each run compares that with the catalog and, when they differ, reuses the `scheduled-updates` branch's copy if it was already built for the catalog's pin from the same `schema-strings/` generator and `strings.xml` header, running Gradle only otherwise. The regenerated English goes up to Crowdin in the same run and rides the scheduled PR with the translations. `RepositorySyncTest` checks the file against the registry only while the recorded pin matches the catalog, so the window between a bump merging and the scheduled PR landing is not red.
|
||||
|
||||
### Runner Strategy (Four Tiers)
|
||||
- **`ubuntu-slim`** — The cheapest tier, and where lightweight jobs belong since #6674/#6677: status gates, labelers, triage, run-cancellers, stale, changelog and release cleanup. Container-backed and starts in seconds, but **single-CPU, unprivileged, x64-only, with a hard 15-minute job cap** — so it fits `gh`/`jq`/`git`/stdlib-`python3`/`github-script` work and nothing needing `sudo`, `apt-get`, Docker, a mounted filesystem, or a long full-history clone.
|
||||
- **`ubuntu-24.04-arm`** — Lightweight jobs that break any of those `ubuntu-slim` constraints (release metadata, `main-check`, promotion, dependency-graph submission). Shorter queue times than x64.
|
||||
- **`ubuntu-24.04`** — Gradle-heavy jobs. Every single-runner job in `reusable-check.yml` pins it (`lint-check`, `screenshot-check`, `rb-check`, `test-shards`, `android-check`), as do release builds, Dokka and docs publishing. The `build-desktop` matrix job spans several runners instead, as does `verify-flatpak` in its own workflow. Pin for reproducibility.
|
||||
- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job, `verify-flatpak`, and release packaging.
|
||||
The tiers are named here and the workflows carry the label versions.
|
||||
|
||||
- **`ubuntu-slim`** is the cheapest tier, for API and script jobs off the required-check path: the labeler, the PR-close run canceller, changelog, the docs link check, the Play listing upload, release cleanup, and the release and promotion jobs that are only API and script work (tag resolution, the version bump, the flatpak-sources and store-screenshot release assets, the GitHub release update, and the Homebrew and Flathub bumps). Container-backed and starts in seconds, but **single-CPU, unprivileged, x64-only, with a hard 15-minute job cap**, so it fits `gh`/`jq`/`git`/stdlib-`python3`/`github-script` work and nothing needing `sudo`, `apt-get`, Docker, a mounted filesystem, or a long full-history clone. It is a separate, smaller pool, so the required `Check Workflow Status` gates stay off it: a gate queued there holds up a finished build.
|
||||
- **The pinned Ubuntu LTS arm label** runs the lightweight jobs that break any of those `ubuntu-slim` constraints or sit on the required-check path: PR and merge-queue change detection, `check-metadata`, the status gates, the docs quality gate, the snapshot publish, the Play upload, promotion and rollout, and the GitHub release. Shorter queue times than x64.
|
||||
- **The pinned Ubuntu LTS x64 label** runs the Gradle-heavy jobs. Every single-runner job in `reusable-check.yml` pins it (`lint-check`, `screenshot-check`, `rb-check`, `test-shards`, `android-check`), as do release builds, Dokka and docs publishing. The `build-desktop` matrix job spans several runners instead, as does `verify-flatpak` in its own workflow. Pin for reproducibility.
|
||||
- **Desktop runners:** a multi-OS matrix (macOS, Windows, and Ubuntu x64 and arm64) for the `build-desktop` job, `verify-flatpak`, and release packaging. Each matrix lists its own labels, which can trail the pinned LTS labels above.
|
||||
|
||||
`.github/instructions/ci-workflows.instructions.md` restates the picking rule for anyone editing a workflow file.
|
||||
|
||||
### 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:
|
||||
`gradle.properties` is tuned for local dev (8g heap, 6g 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
|
||||
- `-Xmx4g` Gradle heap, `-Xmx6g` Kotlin daemon
|
||||
- VFS watching disabled, workers capped at 4
|
||||
- `org.gradle.isolated-projects=true` for better parallelism
|
||||
- Disables unused Android build features (`resvalues`, `shaders`)
|
||||
|
||||
### CI Conventions
|
||||
- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that 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`.
|
||||
- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that depends on `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` for every KMP module in the hand-maintained `ALL_MODULES_FULL` list, plus `assembleAndroidDeviceTest` for `:core:database` and `:core:model`, so a device-test APK that fails to dex or package fails here. `scripts/check-module-list.py` fails the PR when that list drifts from `settings.gradle.kts`. CI runs it in `shard-core`.
|
||||
- **Kotlin warnings fail the test shards:** they pass `-PwarningsAsErrors=true`, which sets `allWarningsAsErrors` on every Kotlin compilation (`KotlinAndroid.kt`, plus `desktopApp` and `schema-strings`). The shards don't run the `compile*MainKotlinMetadata` tasks, so a warning only those report doesn't fail CI (today they warn about duplicate KLIB names). Reproduce locally with the same flag on the compile or test tasks you touched.
|
||||
- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` reads the `ci` Gradle property (`providers.gradleProperty("ci")`) and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`.
|
||||
- **Detekt report formats:** Detekt.kt checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations.
|
||||
- **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.
|
||||
- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` on SDK downloads. Cache key is `robolectric-{os}-{arch}-{hash of gradle/libs.versions.toml}`, restoring from the `robolectric-{os}-{arch}-` prefix, so a catalog change that bumps Robolectric rolls the key without a hand edit.
|
||||
- **`mavenLocal()` gated:** Disabled by default to prevent CI cache poisoning. Pass `-PuseMavenLocal` for local JitPack testing.
|
||||
- **JUnit parallel execution:** Enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races. Cross-module parallelism comes from Gradle forks (`maxParallelForks`).
|
||||
- **Test timeouts:** every Jupiter test and lifecycle method fails after 2 minutes (`junit.jupiter.execution.timeout.default`, `SEPARATE_THREAD` so code that ignores interrupts still fails by name), and every `Test` task stops after 15 minutes, which also covers the JUnit 4 host tests. Both live in `ProjectExtensions.kt`.
|
||||
- **Test retry:** Develocity plugin's native retry (`develocity.testRetry` on each Test task), configured in `ProjectExtensions.kt` (maxRetries=2, maxFailures=10). Screenshot tests opt out (maxRetries=0). The standalone `org.gradle.test-retry` plugin was removed.
|
||||
- **`fail-fast: false`:** Test sharding does not cancel other shards on failure.
|
||||
- **Explicit Gradle task paths:** Prefer `androidApp:lintFdroidDebug` over shorthand `lintDebug` in CI.
|
||||
- **Pull request CI:** Main-only (`.github/workflows/pull-request.yml` targets `main`).
|
||||
- **Merge queue hygiene:** `merge-queue.yml` cancels superseded runs for the same PR (GitHub does not auto-cancel destroyed merge-group runs) and skips the heavy pipeline for docs-only entries (`docs/**`, `*.md`). `rb-check` runs ONLY in the merge queue. `main-check.yml` passes `run_lint: false` — every main commit is a merge-queue-verified merge commit, so main pushes only rebuild the debug APKs for the snapshot release.
|
||||
- **Cache writes:** Trusted on `main` only; merge-queue cache scopes are throwaway branches (writes unrecoverable), so the queue reads only, like all other refs.
|
||||
- **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.
|
||||
- **Pull request CI:** `.github/workflows/pull-request.yml` runs on PRs into `main` and `release/**`. Its Gradle jobs skip the `scheduled-updates` and `scheduled-baseline` head branches; the merge queue still runs everything for them.
|
||||
- **Merge queue hygiene:** `merge-queue.yml`'s `check-changes` job first cancels older runs for the same PR, best effort, because GitHub does not auto-cancel destroyed merge-group runs. It then lists the entry's files from the compare API, with no checkout, and skips the heavy pipeline for docs-only entries (`docs/**`, `fastlane/**`, `obtainium/**`, `*.md`), mirrored by `main-check.yml`'s `paths-ignore`. An entry of 300 or more files, the API's listing cap, runs full CI. Once the gate passes, `Check Workflow Status` posts the required `license/cla` on the group commit if the PR head has it, because cla-assistant.io only checks PRs. `rb-check` runs ONLY in the merge queue. `main-check.yml` passes `run_lint: false` because every main commit is a merge-queue-verified merge commit, so main pushes skip lint, `screenshot-check` and `rb-check`, and run the coverage shards, the debug APKs and the desktop packages for the snapshot release. Its concurrency group never cancels a started run: one run executes, one waits, and a newer push replaces only the waiting one.
|
||||
- **Cache writes:** each cache has its own rule. setup-gradle's Gradle User Home cache is written by `reusable-check.yml` on `main` only (`GRADLE_CACHE_READ_ONLY`), so PRs and the merge queue only read it; outside that workflow, the Google, F-Droid and desktop release builds and `scheduled-baseline.yml` write it and every other caller reads. The Develocity remote build cache is pushed by `push` and `merge_group` builds and by `workflow_dispatch` runs on `main` that have the access key, never by PRs (`MeshtasticDevelocitySettingsPlugin`). `gradle-setup`'s Kotlin/Native (`~/.konan`) and Robolectric caches follow the same `cache_read_only` input: they save on a key miss only when it is not `'true'`, and a read-only run restores them without saving. Kotlin/Native is opt-in through `cache_konan`, which the test shards and the read-only docs builds pass, so only the test shards on `main` write it; `cache_robolectric` opts a job out of the Robolectric 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.). It runs three drift guards, each runnable locally with `python3`: `scripts/check-changes-filter.py` (every module root in `settings.gradle.kts` has a `<root>/**` line in the `android` filter), `scripts/check-module-list.py` (`RootConventionPlugin`'s `ALL_MODULES_FULL` matches `settings.gradle.kts`) and `scripts/check-test-shards.py` (every module with tests is in a `reusable-check.yml` shard or exempted).
|
||||
- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). `release.yml`'s Google and desktop build steps pass `-PaboutLibraries.release=true` to enable remote license fetching; the F-Droid build leaves it off so its output matches F-Droid's reproducible rebuild. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone.
|
||||
|
||||
@@ -35,7 +35,7 @@ full refresh also appends a ~95-line managed SPECKIT GOVERNANCE section to
|
||||
- Package manifest: `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`
|
||||
(`docs/Gemfile` + `docs/Gemfile.lock` belong to the Jekyll docs site only)
|
||||
- Task runners: Gradle wrapper (`gradlew`), convention plugins in `build-logic/`
|
||||
- CI workflows: `.github/workflows/create-or-promote-release.yml`,`.github/workflows/dependency-graph-submit.yml` `.github/workflows/docs-deploy.yml`,`.github/workflows/docs-release.yml` `.github/workflows/main-check.yml`,`.github/workflows/merge-queue.yml` `.github/workflows/msstore-publish.yml`,`.github/workflows/post-release-cleanup.yml` `.github/workflows/pr-closed-cleanup.yml`,`.github/workflows/promote.yml` `.github/workflows/pull-request-target.yml`,`.github/workflows/pull-request.yml` `.github/workflows/release.yml`,`.github/workflows/reusable-check.yml` `.github/workflows/scheduled-baseline.yml`,`.github/workflows/scheduled-updates.yml` `.github/workflows/update-changelog.yml` `.github/workflows/verify-flatpak.yml`,`.github/workflows/winget-publish.yml`
|
||||
- CI workflows: `.github/workflows/create-or-promote-release.yml`, `.github/workflows/docs-deploy.yml`, `.github/workflows/docs-link-check.yml`, `.github/workflows/docs-quality.yml`, `.github/workflows/docs-release.yml`, `.github/workflows/main-check.yml`, `.github/workflows/merge-queue.yml`, `.github/workflows/msstore-publish.yml`, `.github/workflows/play-listing.yml`, `.github/workflows/play-rollout.yml`, `.github/workflows/post-release-cleanup.yml`, `.github/workflows/pr-closed-cleanup.yml`, `.github/workflows/promote.yml`, `.github/workflows/protobufs-bump.yml`, `.github/workflows/pull-request-target.yml`, `.github/workflows/pull-request.yml`, `.github/workflows/release.yml`, `.github/workflows/reusable-check.yml`, `.github/workflows/scheduled-baseline.yml`, `.github/workflows/scheduled-updates.yml`, `.github/workflows/store-screenshots.yml`, `.github/workflows/update-changelog.yml`, `.github/workflows/verify-flatpak.yml`, `.github/workflows/version-bump.yml`, `.github/workflows/winget-publish.yml`
|
||||
- Source paths: `androidApp/`, `desktopApp/`, `core/`, `feature/`, `build-logic/`, `scripts/`
|
||||
- Test paths: `**/src/commonTest/`, `**/src/jvmTest/`, `**/src/androidHostTest/`,
|
||||
`screenshot-tests/`, `docs-screenshots/`, `baselineprofile/`, `core/konsist/`
|
||||
|
||||
@@ -1,4 +1,50 @@
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
==================
|
||||
Version change: 1.4.1 → 1.4.2
|
||||
Modified principles:
|
||||
- I. Kotlin Multiplatform Core: the list of source sets that may hold actual declarations
|
||||
gains `jvmAndroidMain`, the source set Android and Desktop share through
|
||||
meshtastic.kmp.jvm.android, and the iOS source sets `iosMain` and `nativeMain`. Actuals
|
||||
already live in all three, and JVM-only APIs such as java.util.zip cannot move to
|
||||
commonMain. The principle is unchanged: commonMain still
|
||||
holds the business logic and still takes no java.* or android.* imports. PATCH: the
|
||||
text now matches the source sets the modules use.
|
||||
Added sections: none
|
||||
Removed sections: none
|
||||
Templates requiring updates:
|
||||
- .specify/templates/spec-template.md ✅ updated (source-set table: jvmMain is Desktop-only,
|
||||
jvmAndroidMain row added)
|
||||
- .specify/templates/plan-template.md ✅ no change (names no source set)
|
||||
- .specify/templates/checklist-template.md ✅ no change (names no source set)
|
||||
- .specify/templates/tasks-template.md ✅ no reference
|
||||
Downstream references (Amendment Procedure step 3):
|
||||
- .skills/speckit/SKILL.md ✅ updated (declared constitution version)
|
||||
- AGENTS.md ✅ no change (names no actual-declaration source sets; principle count still 7)
|
||||
Follow-up TODOs: none
|
||||
|
||||
SYNC IMPACT REPORT
|
||||
==================
|
||||
Version change: 1.4.0 → 1.4.1
|
||||
Modified principles:
|
||||
- VI. Documentation Freshness: the docs-quality.yml gate also runs on PRs that touch
|
||||
any docs/**/*.md, locale pages included, and runs
|
||||
scripts/docs/sync-locale-front-matter.py --check, which fails when a docs/<locale>/
|
||||
page's layout or nav_order differs from docs/en. The local command list gains that
|
||||
check. PATCH: the principle now describes the gate the workflow already runs.
|
||||
Added sections: none
|
||||
Removed sections: none
|
||||
Templates requiring updates:
|
||||
- .specify/templates/plan-template.md ✅ no change (names no gate trigger)
|
||||
- .specify/templates/checklist-template.md ✅ no change (CHK006 names no gate trigger)
|
||||
- .specify/templates/spec-template.md ✅ no reference
|
||||
- .specify/templates/tasks-template.md ✅ no reference
|
||||
Downstream references (Amendment Procedure step 3):
|
||||
- .skills/speckit/SKILL.md ✅ updated (declared constitution version; its principle VI
|
||||
summary already names the locale check)
|
||||
- AGENTS.md ✅ no change (names no docs gate; principle count still 7)
|
||||
Follow-up TODOs: none
|
||||
|
||||
SYNC IMPACT REPORT
|
||||
==================
|
||||
Version change: 1.3.7 → 1.4.0
|
||||
@@ -104,8 +150,11 @@ MUST be used in place of JVM/Android-specific APIs:
|
||||
- MUST use Okio (not `java.io`), Ktor (not `java.net`/OkHttp in common), Mutex/atomicfu
|
||||
(not `java.util.concurrent`), Room KMP, DataStore KMP, and Koin 4.2+.
|
||||
- MUST NOT import `java.*` or `android.*` in any `commonMain` module.
|
||||
- Platform-specific implementations belong in `androidMain`/`jvmMain` actual
|
||||
declarations only (there is no `desktopMain` source set; Desktop is the `jvm` target).
|
||||
- Platform-specific implementations belong in platform source sets as actual declarations
|
||||
only: `androidMain`, `jvmMain`, `jvmAndroidMain`, and `iosMain` or `nativeMain` (there is
|
||||
no `desktopMain` source set; Desktop is the `jvm` target). `jvmAndroidMain`, enabled by
|
||||
`meshtastic.kmp.jvm.android`, holds the JVM APIs Android and Desktop share, such as
|
||||
`java.util.zip`, which `commonMain` cannot use.
|
||||
<!-- Rationale: Multi-platform parity (Android, Desktop, iOS). Framework bleed in commonMain breaks compilability on non-Android targets. -->
|
||||
|
||||
### II. Zero Lint Tolerance
|
||||
@@ -198,15 +247,18 @@ Governance rules:
|
||||
from Crowdin (`crowdin.yml`) — never hand-edit a locale page; deleting an English page
|
||||
means deleting its locale copies in the same commit.
|
||||
|
||||
Verification tooling — also enforced in CI: `.github/workflows/docs-quality.yml` runs the
|
||||
link check, the coverage check, a two-way `DocBundleLoader.kt` registry check, and the alias
|
||||
registration check as a **blocking** gate on PRs touching `docs/en/**` (freshness stays
|
||||
advisory). Run locally before pushing docs changes:
|
||||
Verification tooling, also enforced in CI: `.github/workflows/docs-quality.yml` runs the
|
||||
link check, the coverage check, a two-way `DocBundleLoader.kt` registry check, the alias
|
||||
registration check, and a locale front matter check (`layout` and `nav_order` in every
|
||||
`docs/<locale>/` page match `docs/en/`) as a **blocking** gate on PRs touching `docs/en/**`
|
||||
or any other `docs/**/*.md`, locale pages included (freshness stays advisory). Run locally
|
||||
before pushing docs changes:
|
||||
|
||||
```bash
|
||||
node scripts/check-doc-coverage.js # every user-facing feature module has a page
|
||||
node scripts/validate-doc-links.js # internal cross-references and image paths resolve
|
||||
node scripts/check-doc-aliases.js # frontmatter aliases are registered in DocBundleLoader.kt
|
||||
python3 scripts/docs/sync-locale-front-matter.py --check # locale layout/nav_order match docs/en; drop --check to restore
|
||||
node scripts/check-doc-freshness.js # advisory: pages >180 days old, or missing last_updated
|
||||
```
|
||||
<!-- Rationale: Documentation drift misleads users and increases support burden. Three distinct consumers means changes must be verified across all delivery channels. -->
|
||||
@@ -290,4 +342,4 @@ summary derived from this constitution. The files `.github/copilot-instructions.
|
||||
Constitution Check confirming all seven principles were evaluated. Complexity violations
|
||||
require explicit justification in the Complexity Tracking table of the plan document.
|
||||
|
||||
**Version**: 1.4.0 | **Ratified**: 2026-05-07 | **Last Amended**: 2026-09-15
|
||||
**Version**: 1.4.2 | **Ratified**: 2026-05-07 | **Last Amended**: 2026-09-28
|
||||
@@ -148,7 +148,8 @@
|
||||
|-----------|--------|---------------|
|
||||
| `commonMain` | [New files / Modified files] | All business logic and UI |
|
||||
| `androidMain` | [None / Platform integration only] | [Justification if needed] |
|
||||
| `jvmMain` | [None / Shared JVM code] | [Justification if needed] |
|
||||
| `jvmMain` | [None / Desktop-only JVM code] | [Justification if needed] |
|
||||
| `jvmAndroidMain` | [None / JVM code shared by Android and Desktop] | [Justification if needed] |
|
||||
|
||||
## Design Standards Compliance
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ You are an expert Android/KMP engineer. Maintain architectural boundaries, use M
|
||||
<context_and_memory>
|
||||
- **Project Goal:** Decouple business logic from Android for multi-platform (Android, Desktop, iOS).
|
||||
- **Agent Memory:** `.agent_memory/` is local-only scratch (git-ignored) — never stage or commit it. Skim the top (most recent) entry of `.agent_memory/session_context.md` for current state — it is capped at ~5 entries; older handovers live in `session_context.archive.md` (read only if you need historical detail).
|
||||
- **Skills Directory (CONSULT THESE FIRST):**
|
||||
- **Skills Directory (CONSULT THESE FIRST):** each is also symlinked into `.claude/skills/`, which is the only path Claude Code discovers skills from; the files themselves live here.
|
||||
- `.skills/project-overview/` - Codebase map, namespacing, **Bootstrap Steps**.
|
||||
- `.skills/kmp-architecture/` - Expect/actual, source-sets, conventions.
|
||||
- `.skills/compose-ui/` - Adaptive UI, **String Resources (consult strings-index.txt first)**.
|
||||
@@ -27,7 +27,7 @@ You are an expert Android/KMP engineer. Maintain architectural boundaries, use M
|
||||
- **Memory Persistence:** Add a new entry to the TOP of `.agent_memory/session_context.md` at the end of every session or major task. Keep it capped at ~5 entries — move anything older to `session_context.archive.md`.
|
||||
- **Bootstrap First:** Run the mandatory bootstrap steps in `.skills/project-overview/SKILL.md` before any build.
|
||||
- **Plan Before Execution:** Use `.agent_plans/` (git-ignored) for complex refactors.
|
||||
- **Baseline Verification:** Always run: `./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests`
|
||||
- **Baseline Verification:** Always run: `./gradlew spotlessApply spotlessCheck detekt detektTypeResolved assembleDebug test allTests`
|
||||
</process_essentials>
|
||||
|
||||
<rules>
|
||||
|
||||
+119
-15
@@ -8,18 +8,72 @@ See [GitHub Releases](https://github.com/meshtastic/Meshtastic-Android/releases)
|
||||
<!-- UNRELEASED_START -->
|
||||
## [Unreleased]
|
||||
|
||||
### Unreleased (not yet in any build)
|
||||
|
||||
#### 🛠️ Fixes
|
||||
* fix(messaging): keep the Enter key in the message composer by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7155
|
||||
* fix(storeforward): dedupe a router replay by original_id by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7158
|
||||
* fix(service): keep the process alive and awake through firmware updates and scans by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7159
|
||||
* fix(messaging): key conversations to channel identity, not slot index by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7162
|
||||
|
||||
### Open Beta (v2.8.2-open.2)
|
||||
Changes since [`v2.8.1`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.8.1):
|
||||
### Internal (v2.8.3-internal.4)
|
||||
Changes since [`v2.8.2`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.8.2):
|
||||
|
||||
#### 🏗️ Features
|
||||
* feat(network): a hidden showcase scenario for Demo Mode by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7377
|
||||
* feat(app): shell-only debug launch switches for onboarding and the trust dialog by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7375
|
||||
* feat(settings): raise the coding rate over a modem preset by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7405
|
||||
* feat(firmware): show installed vs latest bootloader before an upgrade by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7409
|
||||
* feat(auto): notification messaging on Android Auto by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7417
|
||||
* feat(map-maplibre): start with collapsed attribution strip on seconda… by @Tha14 in https://github.com/meshtastic/Meshtastic-Android/pull/7424
|
||||
* perf(store-screenshots): wait for the map to draw instead of a fixed 45 seconds by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7501
|
||||
* feat(desktop): add draggable scrollbars to node and message lists by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7506
|
||||
* feat(notifications): post reactions on their own channel by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7507
|
||||
* feat(admin): add Reboot into DFU mode admin action for nRF52 nodes by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7504
|
||||
#### 🖥️ Desktop
|
||||
* fix(mqtt): remove noop mqtt to allow the mqtt proxy to work on the desktop builds by @Tha14 in https://github.com/meshtastic/Meshtastic-Android/pull/7400
|
||||
* fix(notifications): put every notification on its own channel and tap target by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7415
|
||||
* fix(database): delete old mesh logs in bounded batches by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7423
|
||||
* fix: keep WiFi credentials, addresses and coordinates out of app logs by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7419
|
||||
* fix: data correctness fixes from the Android audit by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7429
|
||||
* fix(node): show the real traceroute map on desktop by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7425
|
||||
* refactor: keep one copy of shared code across platforms by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7432
|
||||
* fix(ui): localize UI strings, fix stale effect captures, one EmptyState by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7430
|
||||
#### 🛠️ Fixes
|
||||
* fix(map): keep the node track map responsive on long tracks by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7373
|
||||
* fix(konsist): anchor the scanned-source inputs at the source roots by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7382
|
||||
* fix(map): frame the mesh clear of the map's own controls by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7385
|
||||
* fix(node): drop the filter bar's own background inside the list header by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7386
|
||||
* fix(appfunctions): index functions where AppSearch has no dynamic schema by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7402
|
||||
* fix(ui): split the link colour per mode by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7406
|
||||
* fix(settings): stop profile import dropping Mesh Beacon settings by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7416
|
||||
* fix(database): never publish a replacement pool while the write lock is held by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7418
|
||||
* fix(prefs): recover corrupt Android prefs files and make toggles atomic by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7420
|
||||
* fix(navigation): keep each tab's state across tab switches by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7421
|
||||
* fix(discovery): keep an unheard node's SNR distinct from 0 dB by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7422
|
||||
* fix(service): correct phone position units and omit missing readings by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7437
|
||||
* fix(wifi-provision): release the BLE peripheral on retry and on leaving by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7453
|
||||
* fix(map-maplibre): clear warnings and collect state with lifecycle by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7460
|
||||
* fix(takserver): log a route export that failed to write by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7459
|
||||
* fix(node): leave positions without a fix out of the GPX track by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7457
|
||||
* fix(service): stop the inbound pipeline waiting on node writes by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7464
|
||||
* fix(app): check a shared map file before importing it by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7463
|
||||
* fix(ui): show byte sizes in decimal units, formatted for the locale by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7469
|
||||
* fix(app): stop cleanly on devices the bundled SQLite can't run on by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7470
|
||||
* fix(service): keep the last-heard write off the inbound lock by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7474
|
||||
* fix(takserver): keep one route file across reinstalls by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7475
|
||||
* fix(ui): show transfer rates and file limits in decimal units by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7476
|
||||
* fix(ble): keep device addresses out of logs by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7478
|
||||
* fix(ble): export the bond wait receiver so bond broadcasts reach it by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7484
|
||||
* fix(firmware): show the percent while a maintenance UF2 downloads by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7485
|
||||
* fix(messaging): let a pinned conversation be unpinned by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7492
|
||||
* fix(app): restore the Apache HTTP legacy library for Google Maps by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7499
|
||||
* fix(metrics): break power chart lines across gaps in readings by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7505
|
||||
#### 📝 Other Changes
|
||||
* refactor(data): page the log export, remove dedupe leftovers by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7449
|
||||
* refactor(map): share the Web Mercator projection with node clustering by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7483
|
||||
|
||||
## New Contributors
|
||||
* @Tha14 made their first contribution in https://github.com/meshtastic/Meshtastic-Android/pull/7400
|
||||
<!-- UNRELEASED_END -->
|
||||
|
||||
<!-- RELEASED_START -->
|
||||
|
||||
## [2.8.2] - 2026-09-26
|
||||
|
||||
### 🏗️ Features
|
||||
* perf(ui): render QR codes at display density instead of fixed 960px by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6793
|
||||
* feat: report Android 17 memory-limiter kills via ApplicationExitInfo by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6792
|
||||
* feat(privacy): shield sensitive UI content from non-tool accessibility services by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6794
|
||||
@@ -53,11 +107,35 @@ Changes since [`v2.8.1`](https://github.com/meshtastic/Meshtastic-Android/releas
|
||||
* feat(ui): share the link from the share dialog, and say what the dialog does by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7126
|
||||
* feat(node): signed and verified indicators in place of the PKI lock by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7117
|
||||
* fix(node): keep a contact's public key when a different one arrives by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7118
|
||||
#### 🖥️ Desktop
|
||||
* feat(settings): show the license notice on the About screen by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7191
|
||||
* feat(map): clamp zoom when framing bounds, and let style images use hardware bitmaps by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7245
|
||||
* refactor(navigation): route deep links through navigation3 UriDeepLinkMatcher by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7246
|
||||
* refactor(settings): read numeric field bounds from the protobufs field registry by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7242
|
||||
* feat(telemetry): surface lightning, PM status and soil-water metrics by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7237
|
||||
* feat(node): rank maker hardware between supported and community in the device badge by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7263
|
||||
* feat(settings): gate config fields on the schema's firmware versions by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7262
|
||||
* feat(settings): hide module settings the node reports compiled out by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7272
|
||||
* feat(mqtt): surface a refused subscription from the broker by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7290
|
||||
* feat(settings): drive the module gates from the schema by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7296
|
||||
* feat(settings): label enum pickers from the schema by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7298
|
||||
* feat(settings): read the labels and helper text the schema already has by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7302
|
||||
* feat(beacon): honour the advertised frequency slot by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7305
|
||||
* feat(settings): emit the enum key prefixes the schema strings use by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7306
|
||||
* feat(search): one M3 search bar, settings search, and node status in search by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7303
|
||||
* feat(settings): show the unit the schema declares by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7304
|
||||
* feat(connections): handle hardware without Bluetooth for Android XR by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7318
|
||||
* feat(connections): handle hardware without USB host for Android XR by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7322
|
||||
* refactor(connections): give Demo Mode its own section by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7326
|
||||
* feat(messaging): show whether a delivery receipt was proven by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7284
|
||||
* feat(messaging): record signing and ack proof on reactions by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7339
|
||||
### 🖥️ Desktop
|
||||
* fix(desktop): disable macOS notifications when the process has no app bundle by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6876
|
||||
* fix(desktop): test the bundle path, not the identifier, before notifying by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6885
|
||||
* fix(desktop): use standard SPDX license identifier for RPM packaging by @RCGV1 in https://github.com/meshtastic/Meshtastic-Android/pull/7043
|
||||
#### 🛠️ Fixes
|
||||
* fix(desktop): pin the Flathub screenshots to a commit that survives the squash by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7243
|
||||
* fix(desktop): keep MapLibre's FFI upcall methods through ProGuard by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7287
|
||||
* refactor(model): parse the selected device address once by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7328
|
||||
### 🛠️ Fixes
|
||||
* fix(navigation): clear deep-link replay cache once applied to the backstack by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6796
|
||||
* fix(ui): give feedback when a contact or channel import arrives while disconnected by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6798
|
||||
* fix(database): rebuild the packet FTS index after the schema-52 table recreation by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6808
|
||||
@@ -102,17 +180,43 @@ Changes since [`v2.8.1`](https://github.com/meshtastic/Meshtastic-Android/releas
|
||||
* fix(nfc): write to tags that have never been NDEF-formatted by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7123
|
||||
* fix(node): stop presenting MQTT-only nodes as unheard on the current LoRa by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7138
|
||||
* fix(node): revert the time frame selector to a segmented row by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7142
|
||||
#### 📝 Other Changes
|
||||
* fix(messaging): keep the Enter key in the message composer by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7155
|
||||
* fix(storeforward): dedupe a router replay by original_id by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7158
|
||||
* fix(service): keep the process alive and awake through firmware updates and scans by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7159
|
||||
* fix(messaging): key conversations to channel identity, not slot index by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7162
|
||||
* fix(ui): drop the lazy list cache windows that crash inside a lookahead scope by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7185
|
||||
* fix(settings): state lockdown's irreversibility in the enable dialog by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7171
|
||||
* fix(network): point the API base URL at the production host by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7197
|
||||
* fix(discovery): write a dwell and its parent check in one transaction by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7204
|
||||
* fix(map): keep the 300 ms ease on MapLibre camera nudges by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7240
|
||||
* fix(ble): replace deprecated preConflate with bufferCapacity by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7236
|
||||
* fix(model): fold 0xAE into the hash of an AEAD channel by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7239
|
||||
* fix(model): align the US first-setup preset with the device screen by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7244
|
||||
* fix(settings): name config exports after the long name, not the short name by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7248
|
||||
* fix(nodes): the direct filter no longer returns MQTT nodes by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7256
|
||||
* fix(mqtt): TLS switch shows and sets the stored flag for the public broker by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7255
|
||||
* fix(connection): name the transport in handshake stall reports by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7276
|
||||
* fix(mqtt): subscribe with the options the negotiated protocol version allows by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7273
|
||||
* fix(analytics): give crash reports the radio that produced them, a real blame frame, and a ceiling by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7274
|
||||
* fix(settings): write the external notification duration in milliseconds by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7307
|
||||
* fix(node): keep the node counts visible while searching by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7311
|
||||
* fix(ble): don't arm a BLE transport on hardware without Bluetooth by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7321
|
||||
* fix(connections): refuse serial addresses without USB host by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7327
|
||||
* fix(service): stop a stale saved address overwriting a newer selection by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7329
|
||||
* fix(service): stay foreground only for an address that can connect by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7330
|
||||
* fix(messaging): clarify the message filter controls by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7324
|
||||
* fix(notifications): give bubbles an adaptive icon by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7336
|
||||
* fix(position): send no coordinates with a position request when we have none by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7361
|
||||
### 📝 Other Changes
|
||||
* refactor(settings): edit the status message on the user screen by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6951
|
||||
* refactor(map): retire the maps-utils workarounds its 5.1 fixes made stale by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7046
|
||||
* refactor(prefs): keep each surface's filters in one state object by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7120
|
||||
* refactor(appfunctions): migrate to AppFunctionServiceEntryPoint by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7340
|
||||
|
||||
## New Contributors
|
||||
* @azchohfi made their first contribution in https://github.com/meshtastic/Meshtastic-Android/pull/6864
|
||||
* @gargomoma made their first contribution in https://github.com/meshtastic/Meshtastic-Android/pull/7119
|
||||
<!-- UNRELEASED_END -->
|
||||
|
||||
<!-- RELEASED_START -->
|
||||
|
||||
## [2.8.1] - 2026-08-20
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Claude-Specific Instructions
|
||||
|
||||
- **Skills:** Load only the `.skills/` module relevant to the current task — don't read them all. Start with `.skills/project-overview/SKILL.md` (codebase map, bootstrap, troubleshooting).
|
||||
- **Skills:** Load only the `.skills/` module relevant to the current task — don't read them all. Start with `.skills/project-overview/SKILL.md` (codebase map, bootstrap, troubleshooting). Each module is symlinked into `.claude/skills/` so it can also be selected by name, since that is the only directory Claude Code discovers skills from.
|
||||
- **Plan Mode:** Use it for changes spanning multiple modules; write plans to `.agent_plans/` (git-ignored).
|
||||
- **Delegate to keep context lean** (this is a 20+ module KMP repo):
|
||||
- **Broad searches** ("where is X used", "find all implementers of Y") → dispatch the `Explore` subagent so file dumps stay out of the main context; you get back the conclusion.
|
||||
- **Gradle builds/tests/lint** → dispatch the `gradle-runner` subagent. A full `assembleDebug`/`allTests` log is thousands of lines; the subagent returns only pass/fail + failing tests. Don't run heavy `./gradlew` tasks inline.
|
||||
- **Symbol navigation** ("where is this defined", "who calls this", "find implementers") → use the `LSP` tool (`goToDefinition` / `findReferences` / `goToImplementation`) instead of reading whole files. `kotlin-language-server` is supplied by the `nixtastic` plugin; this repo configures nothing. If the `LSP` tool answers "No LSP server available for file type: .kt", the plugin is not loaded or the binary is not on PATH — there is nothing to fix in this repo.
|
||||
- **Symbol navigation** ("where is this defined", "who calls this", "find implementers") → `rg` for the symbol, then read only the matching range. There is no Kotlin LSP server: none available today handles KMP reliably, so the `LSP` tool answering "No LSP server available for file type: .kt" is expected.
|
||||
- **Big files are guarded, not free:** `.claude/settings.json` denies the Crowdin locale `strings.xml` files and prompts before reading the base `strings.xml`, `firmware_releases.json`, `emoji-data.json`, and `flatpak-sources.json`. For strings, consult `.skills/compose-ui/strings-index.txt` instead of the raw file.
|
||||
|
||||
## Quick Reference
|
||||
@@ -29,13 +29,13 @@ want real Google Maps tiles (`MAPS_API_KEY=…`). `local.properties` is not read
|
||||
|
||||
**Baseline verification — run before every push** (CI has failed on skipped local checks):
|
||||
```bash
|
||||
./gradlew spotlessApply spotlessCheck detekt assembleDebug test allTests
|
||||
./gradlew spotlessApply spotlessCheck detekt detektTypeResolved assembleDebug test allTests
|
||||
```
|
||||
Both `test` and `allTests` are required: `allTests` covers KMP modules (where the bare `test` task is ambiguous and silently skips), `test` covers pure-Android/JVM modules. Add `kmpSmokeCompile` when touching a KMP module. After adding string resources, run `python3 scripts/sort-strings.py`. Change-type matrix and CI architecture: `.skills/testing-ci/SKILL.md`.
|
||||
Both `test` and `allTests` are required: `allTests` runs each KMP module's `jvmTest` and Android host tests (a KMP module has no `test` task, and naming `:core:data:test` fails as ambiguous), `test` covers pure-Android/JVM modules and skips KMP ones. Add `kmpSmokeCompile` when touching a KMP module. After adding string resources, run `python3 scripts/sort-strings.py`. Change-type matrix and CI architecture: `.skills/testing-ci/SKILL.md`.
|
||||
|
||||
**Single test:**
|
||||
```bash
|
||||
./gradlew :feature:messaging:allTests # one KMP module
|
||||
./gradlew :androidApp:testFdroidDebugUnitTest # one Android/JVM module
|
||||
./gradlew :core:data:allTests --tests "*PacketHandlerTest*" # filter to one class/method
|
||||
./gradlew :core:data:jvmTest --tests "*PacketHandlerTest*" # filter to one class/method (allTests takes no --tests)
|
||||
```
|
||||
+6
-2
@@ -23,6 +23,10 @@ Thank you for your interest in contributing to Meshtastic-Android! We welcome co
|
||||
- **Strings:** Use localised strings via the **Compose Multiplatform Resource** library in `:core:resources`.
|
||||
- Do **not** use the legacy `androidApp/src/main/res/values/strings.xml`.
|
||||
- **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`.
|
||||
- **Schema strings:** every label and description in the protobufs field metadata is generated into
|
||||
`values/schema_strings.xml`, keyed by schema path (`Res.string.schema_lora_hop_limit`). A settings control
|
||||
that edits one protobuf field uses that key. Do not edit the file or write a `schema_` key by hand
|
||||
(`./gradlew :schema-strings:sync` regenerates it); wrong wording is a change to `meshtastic/protobufs`.
|
||||
- **Usage:**
|
||||
```kotlin
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -36,7 +40,7 @@ Thank you for your interest in contributing to Meshtastic-Android! We welcome co
|
||||
|
||||
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.
|
||||
- Run `./gradlew detekt detektTypeResolved` before submitting your pull request to ensure your code passes all lint checks. `detektTypeResolved` runs the rules that need the compile classpath, which plain `detekt` skips.
|
||||
- Fix any Detekt warnings or errors reported in your code.
|
||||
- Suppress individual warnings only as a last resort.
|
||||
- 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.
|
||||
@@ -55,7 +59,7 @@ Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI t
|
||||
- Ensure all tests pass by running:
|
||||
- `./gradlew test` for unit and Robolectric tests (pure-Android modules)
|
||||
- `./gradlew allTests` for KMP module tests (`core:*`, `feature:*`) — neither `test` nor `allTests` alone is sufficient; both must pass.
|
||||
- `./gradlew kmpSmokeCompile` when touching any KMP module — compiles the non-Android targets the unit tests don't cover
|
||||
- `./gradlew kmpSmokeCompile` when touching any KMP module, to compile the non-Android targets the unit tests don't cover and assemble the device-test APKs
|
||||
- `./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.
|
||||
|
||||
@@ -49,8 +49,8 @@ What those two channels point at right now:
|
||||
|
||||
| Channel | Currently | Released |
|
||||
|---|---|---|
|
||||
| **Latest release** | `v2.8.1` | 2026-08-20 |
|
||||
| **Open beta** | `v2.8.2-open.2` | 2026-09-13 |
|
||||
| **Latest release** | `v2.8.2` | 2026-09-25 |
|
||||
| **Open beta** | *none published right now* | — |
|
||||
|
||||
<!-- END GENERATED STATUS -->
|
||||
|
||||
@@ -84,7 +84,7 @@ The two documentation sites below are deployed to GitHub Pages automatically on
|
||||
|
||||
| Site | URL | Contents |
|
||||
|---|---|---|
|
||||
| **User & Developer Docs** | [meshtastic.github.io/Meshtastic-Android](https://meshtastic.github.io/Meshtastic-Android/) | Jekyll site — user guide, developer guide, in-app doc content |
|
||||
| **User & Developer Docs** | [meshtastic.github.io/Meshtastic-Android](https://meshtastic.github.io/Meshtastic-Android/) | Jekyll site with the user and developer guides |
|
||||
| **API Reference** | [meshtastic.github.io/Meshtastic-Android/api](https://meshtastic.github.io/Meshtastic-Android/api/) | Dokka-generated KDoc for all public APIs |
|
||||
|
||||
### Generating Locally
|
||||
@@ -98,7 +98,7 @@ BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll serve \
|
||||
|
||||
**API Reference (Dokka):**
|
||||
```bash
|
||||
./gradlew dokkaGeneratePublicationHtml
|
||||
./gradlew :dokkaGeneratePublicationHtml
|
||||
# Output: build/dokka/html/index.html
|
||||
```
|
||||
|
||||
|
||||
+45
-11
@@ -8,7 +8,7 @@ The entire release process is managed by a single GitHub Action: **`Create or Pr
|
||||
|
||||
- **Trigger:** To start a new release or promote an existing one, a developer runs the workflow from the GitHub Actions tab.
|
||||
- **Inputs:** The workflow requires the following inputs:
|
||||
1. `base_version`: The base version number you are releasing (e.g., `2.8.0`).
|
||||
1. `base_version`: The base version number you are releasing (e.g., `2.8.0`). It must be `X.Y.Z` and equal `VERSION_NAME_BASE` in `config.properties` at the commit being released (`HEAD` for an internal cut, the promoted tag's commit for a promotion), or the run stops before any tag is pushed.
|
||||
2. `channel`: The release channel you are targeting (`internal`, `closed`, `open`, or `production`).
|
||||
3. `dry_run`: If `true`, calculates the tag but does not push it or start the release (default: `false`).
|
||||
4. `no_review_in_flight`: **Promotions only, and a hard gate.** Before promoting, check
|
||||
@@ -18,12 +18,18 @@ The entire release process is managed by a single GitHub Action: **`Create or Pr
|
||||
already in flight. Internal releases and dry runs are exempt (Play internal testing
|
||||
skips full review).
|
||||
- **Automation:** The workflow handles everything automatically:
|
||||
- **Generates Changelog:** Categorizes merged PRs by their labels (per `.github/release.yml`) into GitHub's auto-generated release notes; a production release also opens a PR folding the same notes into `CHANGELOG.md`. Between releases that file is only refreshed by dispatching the `Update Changelog` workflow by hand.
|
||||
- **Tags & Builds** *(internal releases)*: Pushes the incremental tag first — there is no lint/test gate in this workflow, that's the separate PR/CI pipeline — then builds the Android bundle/APK and Desktop installers from that tag; if the build fails, an automatic cleanup job deletes the tag so a retry starts clean. Promotions skip this entirely and retag the already-built artifact (see below).
|
||||
- **Deploys Android:** Uploads the build to the correct Google Play track and attaches artifacts (`.aab`/`.apk`) to a GitHub Release.
|
||||
- **Generates Changelog:** Categorizes merged PRs by their labels (per `.github/release.yml`) into GitHub's auto-generated release notes. The internal draft's notes cover the PRs since the previous published pre-release; a production promotion rewrites them over the whole range since the previous production tag, with the metainfo `<description>` for the version as a Highlights section on top, and opens a PR folding the same notes into `CHANGELOG.md`. Every internal, closed and open run ends by dispatching the `Update Changelog` workflow, which refreshes that file's `[Unreleased]` section through its own PR. Every PR the release automation opens (these two, the screenshot refresh and the version bump) goes through `.github/actions/bot-pr` with `CROWDIN_GITHUB_TOKEN`, so it runs the normal PR checks and merges itself through the queue once they pass.
|
||||
- **Tags & Builds** *(internal releases)*: Pushes the incremental tag first — there is no lint/test gate in this workflow, that's the separate PR/CI pipeline — then builds the Android bundle/APK and Desktop installers from that tag; if the build fails, an automatic cleanup job deletes the tag so a retry starts clean. Once `publish-play` has uploaded the bundle the tag stays, since Play keeps that versionCode; re-run the failed jobs instead. Promotions skip this entirely and retag the already-built artifact (see below).
|
||||
- **Deploys Android:** Uploads the build to the correct Google Play track and attaches artifacts (`.aab`/`.apk`) to a GitHub Release. An internal cut sends the bundle to Play only after every Android, desktop and Flatpak leg has built, so a failed leg and its deleted tag leave nothing on Play. Each promotion also uploads the Play "What's new" text for every locale from `fastlane/metadata/android/<locale>/changelogs/default.txt`, which `scripts/sync-play-changelog.py` renders from the metainfo `<description>` and Crowdin translates.
|
||||
- **Captures the store screenshots** *(internal releases)*: `store-screenshots.yml` runs the real debug apps from the tag, connected to Demo Mode's showcase mesh, on one emulator per flavor and on a virtual display for desktop, and attaches `store-listing-screenshots-google-<versionCode>.zip`, `store-listing-screenshots-fdroid-<versionCode>.zip` and the five desktop PNGs to the draft once it exists, so the draft does not wait on the capture. A set with a shot missing is replaced whole by the committed one, so the metainfo's screenshot URLs still resolve, and the job summary says which set went up. A failed capture never fails the release.
|
||||
- **Publishes the Play listing:** every promotion runs the `play_listing` lane with the tag's text for every locale and the google-flavor screenshots, as a dry run on closed and open and for real on production, held as "changes not sent for review". Production also opens a self-merging PR that writes the fdroid-flavor screenshots back into `fastlane/`, which F-Droid and IzzyOnDroid read from git, and the desktop set into `desktopApp/packaging/linux/screenshots/`.
|
||||
- **Publishes docs:** Every promotion dispatches `docs-release.yml` on the new tag (the tag is created with `GITHUB_TOKEN`, so its tag trigger never fires on its own).
|
||||
- **Refreshes the Obtainium table:** Every promotion dispatches `scheduled-updates.yml` once the release is published, so the Obtainium table in `README.md` follows it without waiting for the scheduled run.
|
||||
- **Writes a checklist:** The promotion run's summary lists what it did, what it dispatched, and what is still done by hand. winget, the Microsoft Store, Homebrew and Flathub each read "will skip: `<SECRET>` unset" when their secret is missing, since those workflows and jobs then submit nothing.
|
||||
- **Reruns:** Re-running a failed `update-github-release` job finds the release under its final tag when the first attempt already moved it, and updates the bot PRs in place; its workflow dispatches run again. The Discord announcement goes out whenever the release step succeeded, whatever failed after it.
|
||||
- **Deploys Desktop** *(internal releases)*: Builds native installers (DMG, MSI, EXE, DEB, RPM, AppImage) and Flatpak sources on a matrix of runners and attaches them to the GitHub Release.
|
||||
- **Changelog:** Both the GitHub Release notes and `CHANGELOG.md` are generated from merged PR labels, not raw commit messages — label PRs correctly (`enhancement`, `bugfix`, etc.) to keep them accurate.
|
||||
- **Not part of this workflow:** Firmware/hardware/device-links lists and Crowdin translations are kept current by a separate hourly workflow, `scheduled-updates.yml` ("Scheduled Updates (Firmware, Hardware, Translations)"), which opens its own PR rather than committing directly — it never runs as part of a release. `VERSION_NAME_BASE` in `config.properties` is likewise never written by automation: a maintainer bumps it by hand in an ordinary PR (e.g. "chore: bump VERSION_NAME_BASE to 2.8.2 (#6820)") before starting a release for a new base version, paired with a matching `<release>` entry in `desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml` — a `pull-request.yml` check fails the PR if that entry is missing. `Create or Promote Release` only *reads* `VERSION_NAME_BASE`/`VERSION_CODE_OFFSET` from `config.properties` to compute the build's version name/code.
|
||||
- **Not part of this workflow:** Firmware/hardware/device-links lists and Crowdin translations are kept current by a separate scheduled workflow, `scheduled-updates.yml` ("Scheduled Updates (Firmware, Hardware, Translations)"), which opens its own PR rather than committing directly and enables auto-merge on it, so it lands through the merge queue once its checks pass. A promotion dispatches it (above) without waiting on it. `VERSION_NAME_BASE` in `config.properties` moves to the next patch version after each production release: `promote.yml` dispatches `version-bump.yml`, which runs `scripts/bump-version-name.py` and opens a self-merging PR carrying the new `<release>` entry in `desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml`, its five `<image>` URLs moved to `releases/download/v<version>/`, and `fastlane/metadata/android/en-US/changelogs/default.txt` rendered from that entry. `pull-request.yml` fails a bump PR missing any of them; the bot PR is opened with `CROWDIN_GITHUB_TOKEN`, so those checks run on it and the merge queue takes it. The entry's paragraph is a placeholder; replace it and re-run `scripts/sync-play-changelog.py` before the next internal cut, because it becomes the release Highlights and Play's "What's new". A minor or major line is a hand PR running the same script, and the workflow skips when `main` is already past the shipped version. `Create or Promote Release` only *reads* `config.properties` at the commit being released: `VERSION_NAME_BASE` must match `base_version`, and `VERSION_CODE_OFFSET` plus that commit's count gives the build's version code.
|
||||
|
||||
## Release Steps
|
||||
|
||||
@@ -38,9 +44,9 @@ The entire release process is managed by a single GitHub Action: **`Create or Pr
|
||||
|
||||
The workflow will:
|
||||
1. **Tag** the current commit on the branch with an incremental internal tag (e.g., `v2.8.0-internal.1`) — no new commit is created; it tags whatever is already at `HEAD`.
|
||||
2. **Build & Deploy** the built Android artifact to the Play Store Internal track.
|
||||
3. **Build Desktop** native installers and Flatpak sources on macOS, Windows, and Linux runners.
|
||||
4. Publish a **draft** pre-release on GitHub with all artifacts attached. It stays a draft until
|
||||
2. **Build** the Android bundle and APKs, and the desktop installers and Flatpak sources on macOS, Windows, and Linux runners.
|
||||
3. **Deploy** the Android bundle to the Play Store Internal track once every build has succeeded.
|
||||
4. Publish a **draft** pre-release on GitHub with all artifacts attached; the store screenshots follow when the capture finishes. It stays a draft until
|
||||
the first promotion (closed/open/production), at which point `promote.yml` un-drafts the
|
||||
*same* release object (retagging it to the new channel's tag) rather than creating a new one.
|
||||
|
||||
@@ -63,14 +69,42 @@ After testing is complete on all pre-release channels, you can create the final
|
||||
|
||||
### 4. Post-Release
|
||||
|
||||
1. **Verify Android:** Check the Google Play Console to ensure the build is available on the correct track.
|
||||
Start from the promotion run's summary: it lists what the run did and dispatched, and what
|
||||
remains by hand.
|
||||
|
||||
1. **Verify Android:** Check the Google Play Console to ensure the build is available on the correct track. A production promotion starts a staged rollout at 10% (open at 50%); widen, complete or halt it with the **`Play Rollout`** workflow (see Staged Rollout below).
|
||||
2. **Verify Desktop:** Download and smoke-test at least one installer (DMG, MSI, or AppImage) from the GitHub Release.
|
||||
3. **Verify the desktop store submissions** *(production only — see below)*: the Microsoft Store
|
||||
submission in Partner Center, and the pull request opened against `microsoft/winget-pkgs`.
|
||||
4. **Merge:** If a `release/*` branch was used for stabilization (CI runs the same PR checks
|
||||
Each store workflow warns in its summary when its secrets are not set and it submitted nothing,
|
||||
and the promotion checklist already says so.
|
||||
4. **Flathub** *(production only)*: merge the `update-flathub` PR in `flathub/org.meshtastic.MeshtasticDesktop` once Flathub's test build passes, or bump it by hand when `FLATHUB_TOKEN` is unset (see Flatpak below).
|
||||
5. **Post-Release Cleanup** *(production only)*: `Docs Release` dispatches `post-release-cleanup.yml` with `confirm_deletion: true` once it has published `/vX.Y.Z/`, deleting the pre-releases, tags and docs snapshots at or below `X.Y.Z` and every production docs copy older than the newest one below `X.Y.Z`. Check that run; a manual dispatch is the retry and defaults to a dry run.
|
||||
6. **Next version line** *(production only)*: the `version-bump.yml` PR bumps `VERSION_NAME_BASE` and merges itself. Replace its placeholder `<description>` before the next internal cut.
|
||||
7. **Merge:** If a `release/*` branch was used for stabilization (CI runs the same PR checks
|
||||
against PRs targeting `release/**` as it does for `main`), merge it back into `main` now
|
||||
that production has shipped.
|
||||
|
||||
### Staged Rollout
|
||||
|
||||
**`Play Rollout`** (`play-rollout.yml`) changes the one `inProgress` release on the `production`
|
||||
or `beta` track. Its inputs are the `track`, an `action` and, for `rollout`, a `fraction`:
|
||||
|
||||
| Action | Effect |
|
||||
|---|---|
|
||||
| `rollout` | Widens the release to `fraction`, which must be above the current fraction and below 1 |
|
||||
| `complete` | Ships the release to every user |
|
||||
| `halt` | Stops the rollout at its current fraction |
|
||||
|
||||
It carries the same `no_review_in_flight` gate as a promotion, because a committed change
|
||||
cancels and restarts any review in flight. It runs in its own concurrency group, not
|
||||
`Create or Promote Release`'s, since a group keeps one pending run and a rollout queued there
|
||||
would cancel a pending promotion. The run stops without
|
||||
changing anything unless the track holds exactly one `inProgress` release, and it verifies the
|
||||
new status and fraction on the track afterwards. A halted release is resumed or completed in the
|
||||
Play Console, since `supply` only acts on `inProgress` releases. When Play will not send a change
|
||||
for review on its own, the change waits under Publishing overview in the Play Console.
|
||||
|
||||
### Desktop Store Publishing (production only)
|
||||
|
||||
Publishing a **production** release also fires two workflows, both keyed on the GitHub
|
||||
@@ -119,7 +153,7 @@ Desktop uses the same version resolution chain as Android — both read `VERSION
|
||||
|
||||
### Flatpak
|
||||
|
||||
Flatpak packaging is maintained externally at [flathub/org.meshtastic.MeshtasticDesktop](https://github.com/flathub/org.meshtastic.MeshtasticDesktop). It builds `:desktopApp:packageUberJarForCurrentOS` (not the native distribution pipeline) and handles JBR bundling; the AppStream metainfo and `.desktop` entry it installs come from this repo, out of the tag it builds. So the desktop screenshots and the `<release>` notes ship with the tag - nothing to do on the Flathub side beyond the version bump. The offline-build sources it consumes are captured in-repo by `scripts/verify-flatpak/` (see its README).
|
||||
Flatpak packaging is maintained externally at [flathub/org.meshtastic.MeshtasticDesktop](https://github.com/flathub/org.meshtastic.MeshtasticDesktop). It builds `:desktopApp:packageUberJarForCurrentOS` (not the native distribution pipeline) and handles JBR bundling; the AppStream metainfo and `.desktop` entry it installs come from this repo, out of the tag it builds. So the `<release>` notes ship with the tag, and the `<screenshot>` URLs name the desktop PNGs the internal cut attached to that version's release (`releases/download/v<version>/`), which Flathub's guidelines allow and a branch link would not. A production promotion's `update-flathub` job opens the bump PR with `FLATHUB_TOKEN`, and skips with a notice when that secret is unset. The PR moves four things together: the tag and commit, the Gradle distribution zip URL and sha256 (from the tag's `gradle/wrapper/gradle-wrapper.properties`), and the release's `flatpak-sources.json` asset. `scripts/verify-flatpak/bump-flathub-manifest.py` rewrites those manifest fields in place and fails, leaving the bump to be done by hand, when any of them no longer matches exactly once. The JBR, the runtime and the patches are left alone; Flathub's test build on the PR checks them against the tag. flathubbot's zip-bump PRs follow the latest Gradle rather than the tag's wrapper and fail their test build; close them rather than merge them. The offline-build sources it consumes are captured in-repo by `scripts/verify-flatpak/` (see its README), and `verify-flatpak.yml` builds them offline nightly and on changes to that directory, the workflow or the Gradle wrapper.
|
||||
|
||||
## Build Attestations & Provenance
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ The module primarily serves as a "glue" layer, connecting:
|
||||
```mermaid
|
||||
graph TB
|
||||
:androidApp[androidApp]:::android-application
|
||||
:androidApp -.-> :baselineprofile
|
||||
:androidApp -.-> :feature:map-maplibre
|
||||
:androidApp -.-> :feature:map-terrain
|
||||
:androidApp -.-> :core:ble
|
||||
|
||||
+14
-19
@@ -124,8 +124,6 @@ configure<ApplicationExtension> {
|
||||
)
|
||||
}
|
||||
ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a") }
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
// Disable ABI splits for bundle builds or when explicitly requested via Gradle property.
|
||||
@@ -174,6 +172,9 @@ configure<ApplicationExtension> {
|
||||
// what a real build carries comes from the plugin. See #6883.
|
||||
manifestPlaceholders["MAPS_API_KEY"] = "dummy"
|
||||
}
|
||||
if (name == "fdroid") {
|
||||
proguardFile("proguard-rules-fdroid.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,24 +198,22 @@ secrets {
|
||||
propertiesFileName = "secrets.properties"
|
||||
}
|
||||
|
||||
ksp { arg("appfunctions:aggregateAppFunctions", "true") }
|
||||
// AppSearch without dynamic-schema support indexes only the v1 XML named by the `android.app.appfunctions` property.
|
||||
ksp { arg("appfunctions:generateV1Xml", "true") }
|
||||
|
||||
// Merging into src/main is what ships the profile in fdroid too.
|
||||
baselineProfile { mergeIntoMain = true }
|
||||
|
||||
// The producer only has the google flavor, so only googleRelease may depend on it: fdroidRelease would fail to resolve
|
||||
// it. The plugin creates this configuration per variant, after this script runs.
|
||||
configurations
|
||||
.matching { it.name == "googleReleaseBaselineProfile" }
|
||||
.configureEach { dependencies.add(projects.baselineprofile) }
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("debug")) { variant ->
|
||||
variant.flavorName?.let { flavor -> variant.applicationId.set("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"
|
||||
// Use tasks.names to check existence without eagerly realizing tasks
|
||||
if (tasks.names.contains(uploadTaskName) && tasks.names.contains(minifyTaskName)) {
|
||||
tasks.named(minifyTaskName).configure { finalizedBy(uploadTaskName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -348,8 +347,4 @@ dependencies {
|
||||
testImplementation(libs.androidx.glance.appwidget)
|
||||
// JVM variant provides the host-platform native library for BundledSQLiteDriver under Robolectric
|
||||
testRuntimeOnly(libs.androidx.sqlite.bundled.jvm)
|
||||
|
||||
// Producer of the baseline profile consumed by the release build. The androidx.baselineprofile
|
||||
// plugin merges the generated rules into src/<variant>/generated/baselineProfiles at build time.
|
||||
baselineProfile(projects.baselineprofile)
|
||||
}
|
||||
@@ -4,53 +4,42 @@
|
||||
<CurrentIssues>
|
||||
<ID>ComplexCondition:MapViewModel.kt:MapViewModel$name.isBlank() || (urlTemplate.isBlank() && localUri == null) || (localUri == null && !isValidTileUrlTemplate(urlTemplate))</ID>
|
||||
<ID>ComplexCondition:MapViewModel.kt:MapViewModel$normalized.name.isBlank() || (normalized.urlTemplate.isBlank() && normalized.localUri == null) || (normalized.localUri == null && !isValidTileUrlTemplate(normalized.urlTemplate))</ID>
|
||||
<ID>ComposableParamOrder:MapView.kt:@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MapsDialog</ID>
|
||||
<ID>CyclomaticComplexMethod:DiscoveryGoogleMap.kt:@OptIn(MapsComposeExperimentalApi::class) @Composable fun DiscoveryGoogleMap</ID>
|
||||
<ID>CyclomaticComplexMethod:DiscoveryOsmMap.kt:@Composable fun DiscoveryOsmMap</ID>
|
||||
<ID>CyclomaticComplexMethod:GeminiNanoDocAssistant.kt:GeminiNanoDocAssistant$override fun answerStream: kotlinx.coroutines.flow.Flow<AIDocAssistantResult></ID>
|
||||
<ID>CyclomaticComplexMethod:GeminiNanoDocAssistant.kt:GeminiNanoDocAssistant$private fun buildContext: ContextResult</ID>
|
||||
<ID>CyclomaticComplexMethod:TracerouteOsmMap.kt:@Composable fun TracerouteOsmMap</ID>
|
||||
<ID>LambdaParameterInRestartableEffect:LocationHandler.kt:onPermissionResult: (Boolean) -> Unit</ID>
|
||||
<ID>LambdaParameterInRestartableEffect:TracerouteOsmMap.kt:onMappableCountChange: (shown: Int, total: Int) -> Unit</ID>
|
||||
<ID>LongMethod:DiscoveryGoogleMap.kt:@OptIn(MapsComposeExperimentalApi::class) @Composable fun DiscoveryGoogleMap</ID>
|
||||
<ID>LongMethod:DiscoveryOsmMap.kt:@Composable fun DiscoveryOsmMap</ID>
|
||||
<ID>LongMethod:GeminiNanoDocAssistant.kt:GeminiNanoDocAssistant$override fun answerStream: kotlinx.coroutines.flow.Flow<AIDocAssistantResult></ID>
|
||||
<ID>LongMethod:GeminiNanoDocAssistant.kt:GeminiNanoDocAssistant$override suspend fun isSupported: Boolean</ID>
|
||||
<ID>LongMethod:GeminiNanoDocAssistant.kt:GeminiNanoDocAssistant$private fun buildPrompt: String</ID>
|
||||
<ID>LongMethod:NodeTrackOsmMap.kt:@Composable fun NodeTrackOsmMap</ID>
|
||||
<ID>LongMethod:TracerouteOsmMap.kt:@Composable fun TracerouteOsmMap</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:FdroidMapOverlayRenderer.kt:FdroidMapOverlayRenderer$for</ID>
|
||||
<ID>LoopWithTooManyJumpStatements:GeminiNanoDocAssistant.kt:GeminiNanoDocAssistant$for</ID>
|
||||
<ID>MatchingDeclarationName:MapView.kt:GoogleMapMode</ID>
|
||||
<ID>ModifierMissing:DownloadButton.kt:@Composable fun DownloadButton</ID>
|
||||
<ID>ModifierMissing:Main.kt:@Composable fun MainScreen</ID>
|
||||
<ID>ModifierMissing:NodeMapScreen.kt:@Composable fun NodeMapScreen</ID>
|
||||
<ID>ModifierMissing:WaypointMarkers.kt:@OptIn(MapsComposeExperimentalApi::class) @Composable fun WaypointMarkers</ID>
|
||||
<ID>ReturnCount:GooglePlatformAnalytics.kt:GooglePlatformAnalytics.CrashlyticsLogWriter$override fun log</ID>
|
||||
<ID>ReturnCount:MapViewModel.kt:MapViewModel$fun getTileProvider: TileProvider?</ID>
|
||||
<ID>ReturnCount:MlKitDocTranslator.kt:MlKitDocTranslator$override suspend fun translatePage: TranslationResult</ID>
|
||||
<ID>ReturnCount:MlKitMessageTranslator.kt:MlKitMessageTranslator$override suspend fun downloadLanguageModels: DownloadResult</ID>
|
||||
<ID>ReturnCount:MlKitMessageTranslator.kt:MlKitMessageTranslator$override suspend fun translate: TranslationResult</ID>
|
||||
<ID>SwallowedException:MapViewModel.kt:MapViewModel$e: Exception</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$@AppFunction(isDescribedByKDoc = true) suspend fun getChannelInfo: GetChannelInfoResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$@AppFunction(isDescribedByKDoc = true) suspend fun getDeviceStatus: GetDeviceStatusResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$@AppFunction(isDescribedByKDoc = true) suspend fun getMeshMetrics: GetMeshMetricsResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$@AppFunction(isDescribedByKDoc = true) suspend fun getNodeDetails: GetNodeDetailsResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$@AppFunction(isDescribedByKDoc = true) suspend fun getNodeList: GetNodeListResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$@AppFunction(isDescribedByKDoc = true) suspend fun getRecentMessages: GetRecentMessagesResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$@AppFunction(isDescribedByKDoc = true) suspend fun sendMessage: SendMessageResponse</ID>
|
||||
<ID>TooGenericExceptionCaught:FdroidMapOverlayRenderer.kt:FdroidMapOverlayRenderer$e: Exception</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$suspend fun getChannelInfo: GetChannelInfoResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$suspend fun getDeviceStatus: GetDeviceStatusResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$suspend fun getMeshMetrics: GetMeshMetricsResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$suspend fun getNodeDetails: GetNodeDetailsResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$suspend fun getNodeList: GetNodeListResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$suspend fun getRecentMessages: GetRecentMessagesResponse</ID>
|
||||
<ID>ThrowsCount:MeshtasticAppFunctions.kt:MeshtasticAppFunctions$suspend fun sendMessage: SendMessageResponse</ID>
|
||||
<ID>TooGenericExceptionCaught:GeminiNanoDocAssistant.kt:GeminiNanoDocAssistant$e: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:MapView.kt:e: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:MapView.kt:ex: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:MapViewModel.kt:MapViewModel$e: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:MlKitDocTranslator.kt:MlKitDocTranslator$e: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:MlKitMessageTranslator.kt:MlKitMessageTranslator$e: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:SqlTileWriterExt.kt:SqlTileWriterExt$e: Exception</ID>
|
||||
<ID>TooManyFunctions:GeminiNanoDocAssistant.kt:GeminiNanoDocAssistant : AIDocAssistant</ID>
|
||||
<ID>TooManyFunctions:MapView.kt:org.meshtastic.app.map.MapView.kt</ID>
|
||||
<ID>TooManyFunctions:MapViewModel.kt:MapViewModel : BaseMapViewModel</ID>
|
||||
<ID>UtilityClassWithPublicConstructor:CustomTileSource.kt:CustomTileSource</ID>
|
||||
<ID>UnnecessaryLaunchedEffect:Main.kt:LaunchedEffect</ID>
|
||||
<ID>UnnecessaryLaunchedEffect:MapView.kt:LaunchedEffect</ID>
|
||||
<ID>UnnecessaryLaunchedEffect:TracerouteMap.kt:LaunchedEffect</ID>
|
||||
<ID>UnnecessaryLaunchedEffect:WaypointMarkers.kt:LaunchedEffect</ID>
|
||||
<ID>UseOrEmpty:MapView.kt:RenderedMapLayer$dataLayer.properties["groundOverlays"] as? List<KmlGroundOverlay> ?: emptyList()</ID>
|
||||
<ID>UseOrEmpty:MapView.kt:RenderedMapLayer$dataLayer.properties["images"] as? Map<String, Bitmap> ?: emptyMap()</ID>
|
||||
<ID>UseOrEmpty:MapView.kt:mode.overlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList()</ID>
|
||||
<ID>UseOrEmpty:MapView.kt:mode.overlay?.returnRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList()</ID>
|
||||
<ID>UseOrEmpty:MapView.kt:tracerouteSelection?.nodesForMarkers ?: emptyList()</ID>
|
||||
<ID>UseOrEmpty:MapViewModel.kt:MapViewModel$uri.path ?: ""</ID>
|
||||
<ID>ViewModelForwarding:MapView.kt:CustomTileProviderManagerSheet(mapViewModel = mapViewModel)</ID>
|
||||
<ID>ViewModelForwarding:MapView.kt:MainMapContent( nodeClusterItems = nodeClusterItems, mapFilterState = mapFilterState, navigateToNodeDetails = navigateToNodeDetails, displayableWaypoints = displayableWaypoints, myNodeNum = myNodeNum, onEditWaypointRequest = { editingWaypoint = it }, onDeleteWaypointRequest = { deletingWaypoint = it }, onShowGeofenceInfo = { geofenceInfoWaypoint = it }, selectedWaypointId = selectedWaypointId, mapLayers = mapLayers, layerOpacity = layerOpacity, mapViewModel = mapViewModel, cameraPositionState = cameraPositionState, coroutineScope = coroutineScope, onShowClusterItemsDialog = { showClusterItemsDialog = it }, )</ID>
|
||||
<ID>ViewModelForwarding:MapView.kt:MapLayerOverlay(layerItem, opacity, mapViewModel)</ID>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# F-Droid has no crash backend to retrace an obfuscated stack, so its builds keep
|
||||
# their names. Play's DEX optimization check only sees the google flavor.
|
||||
-dontobfuscate
|
||||
Vendored
+28
-7
@@ -1,8 +1,10 @@
|
||||
# ============================================================================
|
||||
# Meshtastic Android — ProGuard / R8 rules for release minification
|
||||
# ============================================================================
|
||||
# Open-source project: obfuscation is disabled (readable stack traces). We rely
|
||||
# on R8 optimization + tree-shaking (unused code removal) for APK size reduction.
|
||||
# Release builds are shrunk and optimized. The google flavor is also
|
||||
# obfuscated; its mapping goes to Crashlytics and Datadog and is attached to
|
||||
# each GitHub release. The fdroid flavor adds proguard-rules-fdroid.pro, which
|
||||
# keeps it unobfuscated.
|
||||
#
|
||||
# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room,
|
||||
# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3,
|
||||
@@ -14,11 +16,7 @@
|
||||
|
||||
# ---- General ----------------------------------------------------------------
|
||||
|
||||
# Open-source — no need to obfuscate
|
||||
-dontobfuscate
|
||||
|
||||
# R8 optimization is ENABLED. Obfuscation stays off (-dontobfuscate above), so
|
||||
# stack traces remain readable; tree-shaking plus the full optimization pass
|
||||
# R8 optimization is ENABLED: tree-shaking plus the full optimization pass
|
||||
# (method inlining, class merging, Composer/ComposerImpl devirtualization,
|
||||
# unused-argument removal) all run.
|
||||
#
|
||||
@@ -39,6 +37,29 @@
|
||||
# for auditing. Inspect this file after a release build to see what libraries inject.
|
||||
-printconfiguration build/outputs/mapping/r8-merged-config.txt
|
||||
|
||||
# ---- Names read at runtime --------------------------------------------------
|
||||
# Each name below is looked up by string, so obfuscation must leave it alone.
|
||||
|
||||
# KableGattCacheRefresh reads these private Kable fields by reflection.
|
||||
-keepclassmembernames class com.juul.kable.BluetoothDeviceAndroidPeripheral {
|
||||
kotlinx.coroutines.flow.MutableStateFlow connection;
|
||||
}
|
||||
-keepclassmembernames class com.juul.kable.Connection {
|
||||
android.bluetooth.BluetoothGatt gatt;
|
||||
}
|
||||
|
||||
# rumViewName() reports a route's class name as its Datadog view name.
|
||||
-keepnames class * implements androidx.navigation3.runtime.NavKey
|
||||
|
||||
# isDeprecatedEnumEntry() finds each constant's field by name to read @Deprecated.
|
||||
-keepclassmembernames enum org.meshtastic.** {
|
||||
<fields>;
|
||||
}
|
||||
|
||||
# GooglePlatformAnalytics drops logging frames from Crashlytics stacks by class-name prefix.
|
||||
-keepnames class org.meshtastic.app.analytics.GooglePlatformAnalytics*
|
||||
-keepnames class co.touchlab.kermit.**
|
||||
|
||||
# ---- Networking (transitive references from Ktor on Android) ----------------
|
||||
|
||||
-dontwarn org.conscrypt.**
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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/>.
|
||||
-->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<!--
|
||||
The automation entry point: MainActivity honours its launch switches only when started through this alias.
|
||||
DUMP is held by the shell and never by an installed app, so `adb shell am start` and UiAutomation can use it
|
||||
and nothing else on the device can.
|
||||
-->
|
||||
<activity-alias
|
||||
android:name="org.meshtastic.app.AutomationLauncher"
|
||||
android:targetActivity="org.meshtastic.app.MainActivity"
|
||||
android:permission="android.permission.DUMP"
|
||||
android:exported="true" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -19,15 +19,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!--
|
||||
Required for writing TAK route data packages to ATAK's auto-import directory.
|
||||
Only declared for the F-Droid flavor — the Google Play flavor uses scoped
|
||||
storage (SAF / app-scoped cache) so this permission is not needed there
|
||||
and would violate Play policy.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<application>
|
||||
<!--
|
||||
Register as an "Open in / Send to Meshtastic" target for GeoJSON/KML map files (e.g. the Meshtastic Site
|
||||
|
||||
@@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.LocaleUnitsProvider
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.repository.MapCameraPosition
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
@@ -33,7 +32,6 @@ import org.meshtastic.core.repository.NotificationPrefs
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.feature.map.layers.MapLayerItem
|
||||
import org.meshtastic.feature.map.layers.MapLayersManager
|
||||
@@ -111,19 +109,6 @@ class MapViewModel(
|
||||
fun refreshAllVisibleNetworkLayers() = mapLayersManager.refreshAllVisibleNetworkLayers()
|
||||
|
||||
suspend fun readLayerBytes(layerItem: MapLayerItem): ByteArray? = mapLayersManager.readLayerBytes(layerItem)
|
||||
|
||||
// Injected by the map provider because this SavedStateHandle is not the Navigation 3 entry's route state.
|
||||
private val sitePlannerRequestState = SitePlannerRequestState(nodeRepository.nodeDBbyNum)
|
||||
val sitePlannerRequest: StateFlow<Node?> =
|
||||
sitePlannerRequestState.request.stateInWhileSubscribed(initialValue = null)
|
||||
|
||||
fun setSitePlannerNodeNum(nodeNum: Int?) {
|
||||
sitePlannerRequestState.setNodeNum(nodeNum)
|
||||
}
|
||||
|
||||
fun consumeSitePlannerRequest(nodeNum: Int) {
|
||||
sitePlannerRequestState.consume(nodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface InitialCameraState {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* 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.app.map.node
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.app.map.androidCustomRasterBasemaps
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.feature.map.maplibre.MapLibreNodeTrackMap
|
||||
import org.meshtastic.feature.map.node.NodeMapViewModel
|
||||
|
||||
@Composable
|
||||
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
|
||||
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
|
||||
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = node?.user?.long_name ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
// Only once the node has resolved: 0 is not a node, and a placeholder paints the track in the wrong
|
||||
// colour with no head chip.
|
||||
node?.let { resolved ->
|
||||
MapLibreNodeTrackMap(
|
||||
destNum = resolved.num,
|
||||
positions = positions,
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
customBasemaps = { androidCustomRasterBasemaps() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,11 +35,13 @@ fun NodeTrackMap(
|
||||
modifier: Modifier = Modifier,
|
||||
selectedPositionTime: Int? = null,
|
||||
onPositionSelect: ((Int) -> Unit)? = null,
|
||||
showAttribution: Boolean = true,
|
||||
) = MapLibreNodeTrackMap(
|
||||
destNum = destNum,
|
||||
positions = positions,
|
||||
modifier = modifier,
|
||||
selectedPositionTime = selectedPositionTime,
|
||||
onPositionSelect = onPositionSelect,
|
||||
showAttribution = showAttribution,
|
||||
customBasemaps = { androidCustomRasterBasemaps() },
|
||||
)
|
||||
@@ -19,16 +19,47 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!--
|
||||
Firebase Analytics merges these in, but ADID collection is off and nothing reads the advertising ID. Play's
|
||||
advertising ID declaration must say "No" while they stay removed.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="com.google.android.gms.permission.AD_ID"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_ADSERVICES_AD_ID"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION"
|
||||
tools:node="remove" />
|
||||
|
||||
<application
|
||||
android:name="org.meshtastic.app.GoogleMeshUtilApplication"
|
||||
tools:replace="android:name">
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="${MAPS_API_KEY}" />
|
||||
|
||||
<!--
|
||||
Google Maps' Play services module loads into this app's classloader and, on older Play services, resolves
|
||||
org.apache.http from it. Nothing here references it, but without this the map screen crashes.
|
||||
-->
|
||||
<uses-library
|
||||
android:name="org.apache.http.legacy"
|
||||
android:required="false" />
|
||||
|
||||
<property
|
||||
android:name="android.app.appfunctions.app_metadata"
|
||||
android:resource="@xml/app_metadata" />
|
||||
|
||||
<!--
|
||||
Android Auto messaging through notifications only. Adding <uses name="template" /> or any androidx.car.app
|
||||
service is a different, Closed-track-only program; Play rejects those on Open and Production.
|
||||
-->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!--
|
||||
Register as an "Open in / Send to Meshtastic" target for GeoJSON/KML map files (e.g. the Meshtastic Site
|
||||
Planner's "Send to App" share). Google flavor only — it's the flavor that renders map overlays. Merged into
|
||||
@@ -58,56 +89,21 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
AppFunctions 1.0.0-alpha10 discontinued the appfunctions-service artifact whose
|
||||
manifest used to contribute these two delegate services; apps must now declare
|
||||
them (mirrors the alpha09 appfunctions-service AAR manifest, classes relocated
|
||||
to the androidx.appfunctions package).
|
||||
-->
|
||||
<!-- Generated by the AppFunctions compiler from BaseMeshtasticAppFunctionService. -->
|
||||
<service
|
||||
android:name="androidx.appfunctions.PlatformAppFunctionService"
|
||||
android:enabled="@bool/enablePlatformAppFunctionService"
|
||||
android:name="org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
|
||||
tools:targetApi="36">
|
||||
<property
|
||||
android:name="android.app.appfunctions"
|
||||
android:value="app_functions.xml" />
|
||||
android:value="meshtastic_app_function_service-v1.xml" />
|
||||
<property
|
||||
android:name="android.app.appfunctions.schema"
|
||||
android:value="app_functions_schema.xsd" />
|
||||
<property
|
||||
android:name="android.app.appfunctions.v2"
|
||||
android:value="app_functions_v2.xml" />
|
||||
<intent-filter>
|
||||
<action android:name="android.app.appfunctions.AppFunctionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<!--
|
||||
tools:ignore="Instantiatable": lint can't resolve this library-provided service's superclass chain
|
||||
up to android.app.Service. ExtensionAppFunctionService extends the abstract
|
||||
ExtensionsAppFunctionService, whose base is com.android.extensions.appfunctions.AppFunctionService —
|
||||
a class supplied at runtime by the optional "com.android.extensions.appfunctions" <uses-library>
|
||||
(declared android:required="false" in the appfunctions AAR), not by any compile/lint-classpath
|
||||
artifact. Lint therefore can't prove it's a Service and false-positives. The sibling
|
||||
PlatformAppFunctionService is fine because its chain ends in the compile-SDK
|
||||
android.app.appfunctions.AppFunctionService, so the check stays active there and on our own services.
|
||||
-->
|
||||
<service
|
||||
android:name="androidx.appfunctions.ExtensionAppFunctionService"
|
||||
android:enabled="@bool/enableExtensionAppFunctionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_APP_FUNCTION_SERVICE"
|
||||
tools:ignore="Instantiatable">
|
||||
<property
|
||||
android:name="android.app.appfunctions"
|
||||
android:value="app_functions.xml" />
|
||||
<property
|
||||
android:name="android.app.appfunctions.schema"
|
||||
android:value="app_functions_schema.xsd" />
|
||||
<property
|
||||
android:name="android.app.appfunctions.v2"
|
||||
android:value="app_functions_v2.xml" />
|
||||
android:value="meshtastic_app_function_service.xml" />
|
||||
<intent-filter>
|
||||
<action android:name="android.app.appfunctions.AppFunctionService" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -16,36 +16,20 @@
|
||||
*/
|
||||
package org.meshtastic.app
|
||||
|
||||
import androidx.appfunctions.AppFunctionConfiguration
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.java.KoinJavaComponent.getKoin
|
||||
import org.meshtastic.app.ai.appfunctions.AppFunctionStateSync
|
||||
import org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions
|
||||
|
||||
/**
|
||||
* Google flavor Application subclass that configures App Functions.
|
||||
*
|
||||
* Registers a custom factory so the AppFunctions runtime can instantiate [MeshtasticAppFunctions] with its Koin-managed
|
||||
* dependencies.
|
||||
*/
|
||||
class GoogleMeshUtilApplication :
|
||||
MeshUtilApplication(),
|
||||
AppFunctionConfiguration.Provider {
|
||||
/** Google flavor Application subclass that starts the App Functions enabled-state sync. */
|
||||
class GoogleMeshUtilApplication : MeshUtilApplication() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (!isSupportedDevice) return
|
||||
// Start the AppFunctions enabled-state sync. Resolved here (after startKoin has bound
|
||||
// androidContext) rather than via createdAtStart so that Koin graphs built outside a
|
||||
// running app — verification tests, previews — stay lazily constructible.
|
||||
// Off-main: construction forces the AppFunctionsPrefs subgraph and fires AppSearch binder calls.
|
||||
applicationScope.launch { getKoin().get<AppFunctionStateSync>() }
|
||||
}
|
||||
|
||||
override val appFunctionConfiguration: AppFunctionConfiguration
|
||||
get() =
|
||||
AppFunctionConfiguration.Builder()
|
||||
.addEnclosingClassFactory(MeshtasticAppFunctions::class.java) {
|
||||
getKoin().get<MeshtasticAppFunctions>()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import com.google.firebase.ai.ai
|
||||
import com.google.firebase.ai.type.GenerativeBackend
|
||||
import com.google.firebase.ai.type.PublicPreviewAPI
|
||||
import com.google.firebase.ai.type.content
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -149,8 +150,9 @@ class GeminiNanoDocAssistant(
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
Logger.w(tag = TAG) { "isSupported() check failed: ${e.message}" }
|
||||
_modelStatus.value = ModelReadiness.Unavailable(e.message)
|
||||
false
|
||||
@@ -160,8 +162,9 @@ class GeminiNanoDocAssistant(
|
||||
try {
|
||||
ext.warmUp()
|
||||
Logger.i(tag = TAG) { "Model warmed up successfully" }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
Logger.w(tag = TAG) { "Warmup failed (non-fatal): ${e.message}" }
|
||||
}
|
||||
}
|
||||
@@ -278,8 +281,9 @@ class GeminiNanoDocAssistant(
|
||||
),
|
||||
)
|
||||
return@flow // Success — exit retry loop
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
lastError = e
|
||||
val isBusy =
|
||||
e.message?.contains("BUSY", ignoreCase = true) == true ||
|
||||
@@ -392,12 +396,11 @@ class GeminiNanoDocAssistant(
|
||||
val paragraphs = plainText.split(Regex("\n{2,}")).map { it.trim() }.filter { it.length >= MIN_PARAGRAPH_LEN }
|
||||
|
||||
// Score each paragraph by how many query terms it contains.
|
||||
val scored =
|
||||
paragraphs.map { paragraph ->
|
||||
val lower = paragraph.lowercase()
|
||||
val hits = queryTerms.count { term -> lower.contains(term) }
|
||||
paragraph to hits
|
||||
}
|
||||
val scored = paragraphs.map { paragraph ->
|
||||
val lower = paragraph.lowercase()
|
||||
val hits = queryTerms.count { term -> lower.contains(term) }
|
||||
paragraph to hits
|
||||
}
|
||||
|
||||
// Take paragraphs with hits first (sorted by hits desc), then fill with top paragraphs for context.
|
||||
val withHits = scored.filter { it.second > 0 }.sortedByDescending { it.second }
|
||||
|
||||
+9
-3
@@ -17,6 +17,7 @@
|
||||
package org.meshtastic.app.ai.appfunctions
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.appfunctions.AppFunctionException
|
||||
import androidx.appfunctions.AppFunctionManager
|
||||
import androidx.appfunctions.metadata.AppFunctionName
|
||||
@@ -49,7 +50,8 @@ class AppFunctionStateSync(
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
init {
|
||||
observeAndSync()
|
||||
// Only the API 36 platform service is declared, so below it nothing of ours is ever indexed.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) observeAndSync()
|
||||
}
|
||||
|
||||
private fun observeAndSync() {
|
||||
@@ -101,11 +103,14 @@ class AppFunctionStateSync(
|
||||
} else {
|
||||
AppFunctionManager.APP_FUNCTION_STATE_DISABLED
|
||||
}
|
||||
// Until the system indexes a function (first launch, or just after an update) writing it throws
|
||||
// IllegalArgumentException; the read-back drives the retry.
|
||||
try {
|
||||
manager.setAppFunctionEnabled(functionId, state)
|
||||
} catch (e: AppFunctionException) {
|
||||
// Usually "not indexed yet" on first launch; the read-back drives the retry.
|
||||
Logger.d(e) { "AppFunction $functionId not writable yet" }
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.d(e) { "AppFunction $functionId not indexed yet" }
|
||||
}
|
||||
}
|
||||
if (attempt < MAX_SYNC_ATTEMPTS - 1) delay(RETRY_DELAY_MS)
|
||||
@@ -134,7 +139,8 @@ class AppFunctionStateSync(
|
||||
): List<Pair<String, Boolean>> =
|
||||
if (actual == null) desired else desired.filter { (id, enabled) -> actual[id] != enabled }
|
||||
|
||||
private const val CLASS_PREFIX = "org.meshtastic.app.ai.appfunctions.MeshtasticAppFunctions#"
|
||||
// Mirrors the generated MeshtasticAppFunctionService.FUNCTION_ID_* constants, which are API 36 only.
|
||||
private const val CLASS_PREFIX = "org.meshtastic.app.ai.appfunctions.BaseMeshtasticAppFunctionService#"
|
||||
|
||||
const val SEND_MESSAGE_ID = "${CLASS_PREFIX}sendMessage"
|
||||
const val GET_MESH_STATUS_ID = "${CLASS_PREFIX}getMeshStatus"
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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.app.ai.appfunctions
|
||||
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appfunctions.AppFunction
|
||||
import androidx.appfunctions.AppFunctionIntValueConstraint
|
||||
import androidx.appfunctions.AppFunctionService
|
||||
import androidx.appfunctions.AppFunctionServiceEntryPoint
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.meshtastic.core.data.ai.AiFunctionProvider
|
||||
|
||||
/**
|
||||
* Exposes Meshtastic mesh networking capabilities to system AI assistants via the Android App Functions API. Functions
|
||||
* declared here are discoverable by the system and can be invoked by AI agents such as Gemini.
|
||||
*
|
||||
* KSP generates the concrete [MeshtasticAppFunctionService] and its `meshtastic_app_function_service.xml` asset; the
|
||||
* KDoc on each function is the description agents read.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
|
||||
@AppFunctionServiceEntryPoint(
|
||||
serviceName = "MeshtasticAppFunctionService",
|
||||
appFunctionXmlFileName = "meshtastic_app_function_service",
|
||||
)
|
||||
abstract class BaseMeshtasticAppFunctionService : AppFunctionService() {
|
||||
|
||||
private val appFunctions: MeshtasticAppFunctions by inject()
|
||||
|
||||
/**
|
||||
* Send a text message over the Meshtastic mesh radio network.
|
||||
*
|
||||
* Messages are transmitted to nearby mesh nodes using LoRa radio. The mesh network is ideal for off-grid
|
||||
* communications where cellular service is unavailable.
|
||||
*
|
||||
* @param text The message text to send (max 228 UTF-8 bytes — the mesh payload left after protobuf framing).
|
||||
* @param recipientName Optional name of a specific node to send a direct message to. If omitted, the message is
|
||||
* broadcast to all nodes on the specified channel.
|
||||
* @param channelName Optional channel name to broadcast on. If omitted, uses the primary channel. Ignored when
|
||||
* recipientName is specified.
|
||||
* @return A [SendMessageResponse] with the message ID, channel, and timestamp.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun sendMessage(
|
||||
text: String,
|
||||
recipientName: String? = null,
|
||||
channelName: String? = null,
|
||||
): SendMessageResponse = appFunctions.sendMessage(text, recipientName, channelName)
|
||||
|
||||
/**
|
||||
* Get the current status of the Meshtastic mesh network.
|
||||
*
|
||||
* Returns connection state, number of online nodes, total known nodes, the connected device's battery level, and
|
||||
* the local node name.
|
||||
*
|
||||
* @return A [MeshStatusResponse] with the current mesh network status.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getMeshStatus(): MeshStatusResponse = appFunctions.getMeshStatus()
|
||||
|
||||
/**
|
||||
* List all nodes currently visible on the Meshtastic mesh network.
|
||||
*
|
||||
* Returns detailed information about each node including name, battery level, and last heard time. Nodes are sorted
|
||||
* by most recently heard first.
|
||||
*
|
||||
* @return A list of nodes with their current status and metrics.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getNodeList(): GetNodeListResponse = appFunctions.getNodeList()
|
||||
|
||||
/**
|
||||
* List all available Meshtastic mesh channels and their configurations.
|
||||
*
|
||||
* Returns details about each channel including name, index, primary status, and uplink/downlink settings.
|
||||
*
|
||||
* @return A list of channels with their current configuration.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getChannelInfo(): GetChannelInfoResponse = appFunctions.getChannelInfo()
|
||||
|
||||
/**
|
||||
* Get the status and metrics of the local Meshtastic radio device.
|
||||
*
|
||||
* Returns hardware model, firmware version, battery level, charging status, and current radio state.
|
||||
*
|
||||
* @return Device status with current metrics and configuration.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getDeviceStatus(): GetDeviceStatusResponse = appFunctions.getDeviceStatus()
|
||||
|
||||
/**
|
||||
* Retrieve detailed telemetry and status for a specific mesh node.
|
||||
*
|
||||
* Returns per-node metrics including battery level, signal strength, hardware model, and location data.
|
||||
*
|
||||
* @param nodeId The target node ID (e.g., '!abc12345' or user ID).
|
||||
* @return A [GetNodeDetailsResponse] with detailed node information.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getNodeDetails(nodeId: String): GetNodeDetailsResponse = appFunctions.getNodeDetails(nodeId)
|
||||
|
||||
/**
|
||||
* Retrieve aggregate network metrics and statistics for the entire mesh.
|
||||
*
|
||||
* Returns mesh-wide analytics including total node count, online nodes, average battery level, and health score.
|
||||
*
|
||||
* @return A [GetMeshMetricsResponse] with mesh-wide statistics.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getMeshMetrics(): GetMeshMetricsResponse = appFunctions.getMeshMetrics()
|
||||
|
||||
/**
|
||||
* Retrieve recent messages received over the Meshtastic mesh radio network.
|
||||
*
|
||||
* Returns a list of recent messages from the local message history. Messages are stored locally and do not require
|
||||
* an active mesh connection. Useful for catching up on conversations or reviewing recent communications.
|
||||
*
|
||||
* @param contactName Optional name of a node or channel to filter messages from. If omitted, returns messages from
|
||||
* all contacts sorted by most recent.
|
||||
* @param limit Maximum number of messages to return: 1, 5, 10, 20 or 50. Defaults to 20.
|
||||
* @return A [GetRecentMessagesResponse] containing the list of recent messages.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getRecentMessages(
|
||||
contactName: String? = null,
|
||||
@AppFunctionIntValueConstraint(enumValues = [1, 5, 10, 20, 50])
|
||||
limit: Int = AiFunctionProvider.DEFAULT_MESSAGE_LIMIT,
|
||||
): GetRecentMessagesResponse = appFunctions.getRecentMessages(contactName, limit)
|
||||
|
||||
/**
|
||||
* Get a summary of unread messages across all Meshtastic mesh contacts.
|
||||
*
|
||||
* Returns the total unread count and a per-contact breakdown showing who sent unread messages, how many are unread,
|
||||
* and a preview of the last message. Muted contacts are excluded. Does not require an active mesh connection.
|
||||
*
|
||||
* @return A [GetUnreadSummaryResponse] with the total unread count and per-contact details.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getUnreadSummary(): GetUnreadSummaryResponse = appFunctions.getUnreadSummary()
|
||||
}
|
||||
+11
-122
@@ -16,10 +16,7 @@
|
||||
*/
|
||||
package org.meshtastic.app.ai.appfunctions
|
||||
|
||||
import androidx.appfunctions.AppFunction
|
||||
import androidx.appfunctions.AppFunctionContext
|
||||
import androidx.appfunctions.AppFunctionElementNotFoundException
|
||||
import androidx.appfunctions.AppFunctionIntValueConstraint
|
||||
import androidx.appfunctions.AppFunctionInvalidArgumentException
|
||||
import androidx.appfunctions.AppFunctionNotSupportedException
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
@@ -27,35 +24,12 @@ import org.meshtastic.core.data.ai.AiFunctionProvider
|
||||
import org.meshtastic.core.data.ai.SendMessageResult
|
||||
|
||||
/**
|
||||
* Exposes Meshtastic mesh networking capabilities to system AI assistants via the Android App Functions API. Functions
|
||||
* declared here are discoverable by the system and can be invoked by AI agents such as Gemini.
|
||||
* Maps [AiFunctionProvider] results to App Functions responses and exceptions. [BaseMeshtasticAppFunctionService]
|
||||
* declares the functions agents see and delegates each one here.
|
||||
*/
|
||||
// The AppFunctions calling convention requires every @AppFunction to take AppFunctionContext as
|
||||
// its first parameter, even when the implementation never reads it.
|
||||
@Suppress("UnusedParameter")
|
||||
class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
|
||||
/**
|
||||
* Send a text message over the Meshtastic mesh radio network.
|
||||
*
|
||||
* Messages are transmitted to nearby mesh nodes using LoRa radio. The mesh network is ideal for off-grid
|
||||
* communications where cellular service is unavailable.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @param text The message text to send (max 228 UTF-8 bytes — the mesh payload left after protobuf framing).
|
||||
* @param recipientName Optional name of a specific node to send a direct message to. If omitted, the message is
|
||||
* broadcast to all nodes on the specified channel.
|
||||
* @param channelName Optional channel name to broadcast on. If omitted, uses the primary channel. Ignored when
|
||||
* recipientName is specified.
|
||||
* @return A [SendMessageResponse] with the message ID, channel, and timestamp.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun sendMessage(
|
||||
context: AppFunctionContext,
|
||||
text: String,
|
||||
recipientName: String? = null,
|
||||
channelName: String? = null,
|
||||
): SendMessageResponse {
|
||||
suspend fun sendMessage(text: String, recipientName: String?, channelName: String?): SendMessageResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.sendMessage(text, recipientName, channelName)
|
||||
@@ -91,17 +65,7 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status of the Meshtastic mesh network.
|
||||
*
|
||||
* Returns connection state, number of online nodes, total known nodes, the connected device's battery level, and
|
||||
* the local node name.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return A [MeshStatusResponse] with the current mesh network status.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getMeshStatus(context: AppFunctionContext): MeshStatusResponse {
|
||||
suspend fun getMeshStatus(): MeshStatusResponse {
|
||||
val status =
|
||||
try {
|
||||
provider.getMeshStatus()
|
||||
@@ -120,17 +84,7 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all nodes currently visible on the Meshtastic mesh network.
|
||||
*
|
||||
* Returns detailed information about each node including name, battery level, and last heard time. Nodes are sorted
|
||||
* by most recently heard first.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return A list of nodes with their current status and metrics.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getNodeList(context: AppFunctionContext): GetNodeListResponse {
|
||||
suspend fun getNodeList(): GetNodeListResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getNodeList()
|
||||
@@ -163,16 +117,7 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available Meshtastic mesh channels and their configurations.
|
||||
*
|
||||
* Returns details about each channel including name, index, primary status, and uplink/downlink settings.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return A list of channels with their current configuration.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getChannelInfo(context: AppFunctionContext): GetChannelInfoResponse {
|
||||
suspend fun getChannelInfo(): GetChannelInfoResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getChannelInfo()
|
||||
@@ -205,16 +150,7 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status and metrics of the local Meshtastic radio device.
|
||||
*
|
||||
* Returns hardware model, firmware version, battery level, charging status, and current radio state.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return Device status with current metrics and configuration.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getDeviceStatus(context: AppFunctionContext): GetDeviceStatusResponse {
|
||||
suspend fun getDeviceStatus(): GetDeviceStatusResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getDeviceStatus()
|
||||
@@ -243,17 +179,7 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve detailed telemetry and status for a specific mesh node.
|
||||
*
|
||||
* Returns per-node metrics including battery level, signal strength, hardware model, and location data.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @param nodeId The target node ID (e.g., '!abc12345' or user ID).
|
||||
* @return A [GetNodeDetailsResponse] with detailed node information.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getNodeDetails(context: AppFunctionContext, nodeId: String): GetNodeDetailsResponse {
|
||||
suspend fun getNodeDetails(nodeId: String): GetNodeDetailsResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getNodeDetails(nodeId)
|
||||
@@ -294,16 +220,7 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve aggregate network metrics and statistics for the entire mesh.
|
||||
*
|
||||
* Returns mesh-wide analytics including total node count, online nodes, average battery level, and health score.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return A [GetMeshMetricsResponse] with mesh-wide statistics.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getMeshMetrics(context: AppFunctionContext): GetMeshMetricsResponse {
|
||||
suspend fun getMeshMetrics(): GetMeshMetricsResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getMeshMetrics()
|
||||
@@ -332,25 +249,7 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve recent messages received over the Meshtastic mesh radio network.
|
||||
*
|
||||
* Returns a list of recent messages from the local message history. Messages are stored locally and do not require
|
||||
* an active mesh connection. Useful for catching up on conversations or reviewing recent communications.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @param contactName Optional name of a node or channel to filter messages from. If omitted, returns messages from
|
||||
* all contacts sorted by most recent.
|
||||
* @param limit Maximum number of messages to return (1–50). Defaults to 20.
|
||||
* @return A [GetRecentMessagesResponse] containing the list of recent messages.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getRecentMessages(
|
||||
context: AppFunctionContext,
|
||||
contactName: String? = null,
|
||||
@AppFunctionIntValueConstraint(enumValues = [1, 5, 10, 20, 50])
|
||||
limit: Int = AiFunctionProvider.DEFAULT_MESSAGE_LIMIT,
|
||||
): GetRecentMessagesResponse {
|
||||
suspend fun getRecentMessages(contactName: String?, limit: Int): GetRecentMessagesResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getRecentMessages(contactName, limit)
|
||||
@@ -381,17 +280,7 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of unread messages across all Meshtastic mesh contacts.
|
||||
*
|
||||
* Returns the total unread count and a per-contact breakdown showing who sent unread messages, how many are unread,
|
||||
* and a preview of the last message. Muted contacts are excluded. Does not require an active mesh connection.
|
||||
*
|
||||
* @param context The app function invocation context provided by the system.
|
||||
* @return A [GetUnreadSummaryResponse] with the total unread count and per-contact details.
|
||||
*/
|
||||
@AppFunction(isDescribedByKDoc = true)
|
||||
suspend fun getUnreadSummary(context: AppFunctionContext): GetUnreadSummaryResponse {
|
||||
suspend fun getUnreadSummary(): GetUnreadSummaryResponse {
|
||||
val result =
|
||||
try {
|
||||
provider.getUnreadSummary()
|
||||
|
||||
+85
-3
@@ -16,10 +16,12 @@
|
||||
*/
|
||||
package org.meshtastic.app.analytics
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.provider.Settings
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -57,6 +59,7 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.app.BuildConfig
|
||||
import org.meshtastic.core.common.log.ErrorReportThrottle
|
||||
import org.meshtastic.core.common.log.shouldDowngradeForDatadog
|
||||
import org.meshtastic.core.common.log.shouldReportAsException
|
||||
import org.meshtastic.core.repository.AnalyticsPrefs
|
||||
@@ -109,8 +112,15 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
||||
private const val KEY_PRIORITY = "priority"
|
||||
private const val KEY_TAG = "tag"
|
||||
private const val KEY_MESSAGE = "message"
|
||||
private const val KEY_SUPPRESSED = "suppressed_since_last_report"
|
||||
private const val KEY_FIRMWARE_VERSION = "firmware_version"
|
||||
private const val KEY_DEVICE_HARDWARE = "device_hardware"
|
||||
}
|
||||
|
||||
/** Separate budgets per backend — see the comment in [DatadogLogWriter]. */
|
||||
private val reportThrottle = ErrorReportThrottle(nowMs = SystemClock::elapsedRealtime)
|
||||
private val datadogThrottle = ErrorReportThrottle(nowMs = SystemClock::elapsedRealtime)
|
||||
|
||||
init {
|
||||
// Setup Kermit log writers immediately, they will handle delayed SDK initialization gracefully.
|
||||
val writers = buildList {
|
||||
@@ -190,6 +200,14 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
||||
.build()
|
||||
Rum.enable(rumConfiguration)
|
||||
|
||||
val activityManager = application.getSystemService(ActivityManager::class.java)
|
||||
heapLimitAttributes(
|
||||
memoryClassMb = activityManager?.memoryClass,
|
||||
largeMemoryClassMb = activityManager?.largeMemoryClass,
|
||||
maxMemoryBytes = Runtime.getRuntime().maxMemory(),
|
||||
)
|
||||
.forEach { (key, value) -> GlobalRumMonitor.get().addAttribute(key, value) }
|
||||
|
||||
val logsConfig = LogsConfiguration.Builder().build()
|
||||
Logs.enable(logsConfig)
|
||||
|
||||
@@ -272,8 +290,24 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
||||
}
|
||||
|
||||
override fun setDeviceAttributes(firmwareVersion: String, model: String) {
|
||||
val semanticFirmware = firmwareVersion.extractSemanticVersion()
|
||||
|
||||
// The connected radio is the most diagnostic axis this app has, and crash triage happens in Crashlytics.
|
||||
// Without these a crash report says nothing about which hardware or firmware produced it. Keys are sticky
|
||||
// for the process and are refreshed on every connect; there is no disconnect hook on PlatformAnalytics, so
|
||||
// a crash after an explicit disconnect still carries the last radio's values.
|
||||
// Deliberately not gated on isCrashlyticsCollectionEnabled: setting a key is legal while collection is off,
|
||||
// and updateAnalyticsConsent does not replay device attributes, so gating would leave the keys unset until
|
||||
// the next connect for anyone who grants consent after pairing a radio.
|
||||
if (isFirebaseInitialized) {
|
||||
Firebase.crashlytics.setCustomKeys {
|
||||
key(KEY_FIRMWARE_VERSION, semanticFirmware)
|
||||
key(KEY_DEVICE_HARDWARE, model)
|
||||
}
|
||||
}
|
||||
|
||||
if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
|
||||
GlobalRumMonitor.get().addAttribute("firmware_version", firmwareVersion.extractSemanticVersion())
|
||||
GlobalRumMonitor.get().addAttribute("firmware_version", semanticFirmware)
|
||||
GlobalRumMonitor.get().addAttribute("device_hardware", model)
|
||||
}
|
||||
|
||||
@@ -330,6 +364,12 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
||||
// Cancellations and expected conditions stay breadcrumbs only — see shouldReportAsException.
|
||||
if (!shouldReportAsException(severity, throwable)) return
|
||||
|
||||
val suppressed = reportThrottle.acquire(ErrorReportThrottle.signature(tag, message)) ?: return
|
||||
|
||||
// Custom keys stay set for every later report, so this is written unconditionally — skipping it when
|
||||
// the count is zero would leave an earlier report's positive count attached to this one.
|
||||
Firebase.crashlytics.setCustomKeys { key(KEY_SUPPRESSED, suppressed) }
|
||||
|
||||
if (throwable != null) {
|
||||
Firebase.crashlytics.recordException(throwable)
|
||||
} else {
|
||||
@@ -338,7 +378,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
||||
key(KEY_TAG, tag)
|
||||
key(KEY_MESSAGE, message)
|
||||
}
|
||||
Firebase.crashlytics.recordException(Exception(message))
|
||||
Firebase.crashlytics.recordException(loggedException(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,6 +391,15 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
||||
// error tracking while still emitting the log line. Note this deliberately keeps CancellationException
|
||||
// at error here even though Crashlytics drops it; see shouldDowngradeForDatadog.
|
||||
val effectiveSeverity = if (shouldDowngradeForDatadog(severity, throwable)) Severity.Warn else severity
|
||||
|
||||
// Only error-level logs become RUM errors, so only those are worth throttling; everything below stays a
|
||||
// plain log line and is cheap. Sharing the throttle with the Crashlytics writer would halve each
|
||||
// backend's allowance, so they keep separate budgets for the same signature.
|
||||
var suppressed = 0
|
||||
if (effectiveSeverity >= Severity.Error) {
|
||||
suppressed = datadogThrottle.acquire(ErrorReportThrottle.signature(tag, message)) ?: return
|
||||
}
|
||||
|
||||
val datadogPriority =
|
||||
when (effectiveSeverity) {
|
||||
Severity.Verbose -> android.util.Log.VERBOSE
|
||||
@@ -360,7 +409,12 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
||||
Severity.Error -> android.util.Log.ERROR
|
||||
Severity.Assert -> android.util.Log.ASSERT
|
||||
}
|
||||
logger.log(datadogPriority, message, throwable, mapOf("tag" to tag))
|
||||
val attributes =
|
||||
buildMap<String, Any> {
|
||||
put("tag", tag)
|
||||
if (suppressed > 0) put(KEY_SUPPRESSED, suppressed)
|
||||
}
|
||||
logger.log(datadogPriority, message, throwable, attributes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,3 +455,31 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
|
||||
Firebase.analytics.logEvent(event, bundle)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class-name prefixes of the logging machinery that sits between a call site and [loggedException].
|
||||
*
|
||||
* Resolved at runtime rather than written as literals so they still match after R8 renames them. Kermit is matched by
|
||||
* the package of [LogWriter], which is the Kermit type this file already depends on.
|
||||
*/
|
||||
private val loggingFramePrefixes: List<String> =
|
||||
listOfNotNull(
|
||||
GooglePlatformAnalytics::class.java.name,
|
||||
LogWriter::class.java.name.substringBeforeLast('.', "").takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds the stand-in exception for a log line that carried no [Throwable].
|
||||
*
|
||||
* Crashlytics groups a report by the topmost frame it can attribute. Because the exception is constructed inside the
|
||||
* log writer, every such stack starts with that writer and Kermit's dispatch, and Crashlytics walks past synthetic
|
||||
* coroutine lambda frames looking for something nameable — so every error logged from inside a `suspend` block
|
||||
* collapsed into a single `BaseLogger.processLog` issue (4,636 events across 1,203 users in a day and a half, hiding a
|
||||
* dozen unrelated signatures). Dropping the logging frames puts the real call site on top.
|
||||
*
|
||||
* Falls back to the untrimmed stack if the trim would leave nothing, since R8 may repackage these classes.
|
||||
*/
|
||||
private fun loggedException(message: String): Exception = Exception(message).apply {
|
||||
val callerFrames = stackTrace.dropWhile { frame -> loggingFramePrefixes.any { frame.className.startsWith(it) } }
|
||||
if (callerFrames.isNotEmpty()) stackTrace = callerFrames.toTypedArray()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.app.analytics
|
||||
|
||||
private const val BYTES_PER_MB = 1024L * 1024L
|
||||
|
||||
/**
|
||||
* RUM attributes for the heap ceilings the device gives this process, all in the MB `ActivityManager.memoryClass` uses.
|
||||
* The memory classes are left out when `ActivityManager` is unavailable.
|
||||
*/
|
||||
internal fun heapLimitAttributes(
|
||||
memoryClassMb: Int?,
|
||||
largeMemoryClassMb: Int?,
|
||||
maxMemoryBytes: Long,
|
||||
): Map<String, Any> = buildMap {
|
||||
memoryClassMb?.let { put("heap_memory_class_mb", it) }
|
||||
largeMemoryClassMb?.let { put("heap_large_memory_class_mb", it) }
|
||||
put("heap_max_memory_mb", maxMemoryBytes / BYTES_PER_MB)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import com.google.mlkit.genai.prompt.Generation
|
||||
import com.google.mlkit.genai.prompt.GenerativeModel
|
||||
import com.google.mlkit.genai.prompt.TextPart
|
||||
import com.google.mlkit.genai.prompt.generateContentRequest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
|
||||
import org.meshtastic.core.database.entity.DiscoverySessionEntity
|
||||
import org.meshtastic.feature.discovery.DiscoverySummaryGenerator
|
||||
@@ -94,6 +95,8 @@ class GeminiNanoSummaryProvider(private val generator: DiscoverySummaryGenerator
|
||||
} else {
|
||||
text
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
log.w(e) { "Gemini Nano generation failed, using fallback" }
|
||||
fallback()
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
/*
|
||||
* 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.app.map
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.android.gms.common.api.ResolvableApiException
|
||||
import com.google.android.gms.location.LocationRequest
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.LocationSettingsRequest
|
||||
import com.google.android.gms.location.Priority
|
||||
|
||||
private const val INTERVAL_MILLIS = 10000L
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun LocationPermissionsHandler(onPermissionResult: (Boolean) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var localHasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
}
|
||||
|
||||
val requestLocationPermissionLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||
localHasPermission = isGranted
|
||||
// Defer to the LaunchedEffect(localHasPermission) to check settings before confirming via
|
||||
// onPermissionResult
|
||||
// if permission is granted. If not granted, immediately report false.
|
||||
if (!isGranted) {
|
||||
onPermissionResult(false)
|
||||
}
|
||||
}
|
||||
|
||||
val locationSettingsLauncher =
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartIntentSenderForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
Logger.d { "Location settings changed by user." }
|
||||
// User has enabled location services or improved accuracy.
|
||||
onPermissionResult(true) // Settings are now adequate, and permission was already granted.
|
||||
} else {
|
||||
Logger.d { "Location settings change cancelled by user." }
|
||||
// User chose not to change settings. The permission itself is still granted,
|
||||
// but the experience might be degraded. For the purpose of enabling map features,
|
||||
// we consider this as success if the core permission is there.
|
||||
// If stricter handling is needed (e.g., block feature if settings not optimal),
|
||||
// this logic might change.
|
||||
onPermissionResult(localHasPermission)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Initial permission check
|
||||
when (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
|
||||
PackageManager.PERMISSION_GRANTED -> {
|
||||
if (!localHasPermission) {
|
||||
localHasPermission = true
|
||||
}
|
||||
// If permission is already granted, proceed to check location settings.
|
||||
// The LaunchedEffect(localHasPermission) will handle this.
|
||||
// No need to call onPermissionResult(true) here yet, let settings check complete.
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Request permission if not granted. The launcher's callback will update localHasPermission.
|
||||
requestLocationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(localHasPermission) {
|
||||
// Handles logic after permission status is known/updated
|
||||
if (localHasPermission) {
|
||||
// Permission is granted, now check location settings
|
||||
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, INTERVAL_MILLIS).build()
|
||||
|
||||
val builder = LocationSettingsRequest.Builder().addLocationRequest(locationRequest)
|
||||
|
||||
val client = LocationServices.getSettingsClient(context)
|
||||
val task = client.checkLocationSettings(builder.build())
|
||||
|
||||
task.addOnSuccessListener {
|
||||
Logger.d { "Location settings are satisfied." }
|
||||
onPermissionResult(true) // Permission granted and settings are good
|
||||
}
|
||||
|
||||
task.addOnFailureListener { exception ->
|
||||
if (exception is ResolvableApiException) {
|
||||
try {
|
||||
val intentSenderRequest = IntentSenderRequest.Builder(exception.resolution).build()
|
||||
locationSettingsLauncher.launch(intentSenderRequest)
|
||||
// Result of this launch will be handled by locationSettingsLauncher's callback
|
||||
} catch (sendEx: ActivityNotFoundException) {
|
||||
Logger.d { "Error launching location settings resolution ${sendEx.message}." }
|
||||
onPermissionResult(true) // Permission is granted, but settings dialog failed. Proceed.
|
||||
}
|
||||
} else {
|
||||
Logger.d { "Location settings are not satisfiable.${exception.message}" }
|
||||
onPermissionResult(true) // Permission is granted, but settings not ideal. Proceed.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If permission is not granted, report false.
|
||||
// This case is primarily handled by the requestLocationPermissionLauncher's callback
|
||||
// if the initial state was denied, or if user denies it.
|
||||
onPermissionResult(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Paint
|
||||
import android.location.Location
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -42,7 +43,6 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
@@ -59,8 +59,17 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.android.gms.location.FusedLocationProviderClient
|
||||
@@ -70,6 +79,7 @@ import com.google.android.gms.location.LocationResult
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.maps.CameraUpdateFactory
|
||||
import com.google.android.gms.maps.model.BitmapDescriptor
|
||||
import com.google.android.gms.maps.model.BitmapDescriptorFactory
|
||||
import com.google.android.gms.maps.model.CameraPosition
|
||||
import com.google.android.gms.maps.model.GroundOverlay
|
||||
@@ -77,6 +87,8 @@ import com.google.android.gms.maps.model.GroundOverlayOptions
|
||||
import com.google.android.gms.maps.model.JointType
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.android.gms.maps.model.LatLngBounds
|
||||
import com.google.android.gms.maps.model.StrokeStyle
|
||||
import com.google.android.gms.maps.model.StyleSpan
|
||||
import com.google.maps.android.SphericalUtil
|
||||
import com.google.maps.android.compose.CameraPositionState
|
||||
import com.google.maps.android.compose.Circle
|
||||
@@ -88,7 +100,7 @@ import com.google.maps.android.compose.MapType
|
||||
import com.google.maps.android.compose.MapUiSettings
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
import com.google.maps.android.compose.MarkerComposable
|
||||
import com.google.maps.android.compose.MarkerInfoWindowComposable
|
||||
import com.google.maps.android.compose.MarkerInfoWindow
|
||||
import com.google.maps.android.compose.Polygon
|
||||
import com.google.maps.android.compose.Polyline
|
||||
import com.google.maps.android.compose.TileOverlay
|
||||
@@ -109,9 +121,11 @@ import com.google.maps.android.data.renderer.model.PointStyle
|
||||
import com.google.maps.android.data.renderer.model.PolygonStyle
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
@@ -130,14 +144,18 @@ import org.meshtastic.app.map.offline.terrain.ContourOverlay
|
||||
import org.meshtastic.app.map.offline.terrain.HillshadeTileProvider
|
||||
import org.meshtastic.app.map.tiles.RasterBasemap
|
||||
import org.meshtastic.core.common.util.MeasurementSystem
|
||||
import org.meshtastic.core.common.util.NumberFormatter
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.TracerouteOverlay
|
||||
import org.meshtastic.core.model.geofence.toGeofence
|
||||
import org.meshtastic.core.model.hasFix
|
||||
import org.meshtastic.core.model.isLocked
|
||||
import org.meshtastic.core.model.isModifiableBy
|
||||
import org.meshtastic.core.model.util.GeoConstants.DEG_D
|
||||
import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
|
||||
import org.meshtastic.core.model.util.TimeConstants
|
||||
import org.meshtastic.core.model.util.isValidCodePoint
|
||||
import org.meshtastic.core.model.util.kmhIn
|
||||
import org.meshtastic.core.model.util.metersIn
|
||||
@@ -167,7 +185,6 @@ import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.core.ui.icon.Layers
|
||||
import org.meshtastic.core.ui.icon.Map
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.TripOrigin
|
||||
import org.meshtastic.core.ui.theme.TracerouteColors
|
||||
import org.meshtastic.core.ui.util.ActiveWhileStarted
|
||||
import org.meshtastic.core.ui.util.KeepScreenOn
|
||||
@@ -175,9 +192,11 @@ import org.meshtastic.core.ui.util.PermissionStatus
|
||||
import org.meshtastic.core.ui.util.formatAgo
|
||||
import org.meshtastic.core.ui.util.formatPositionTime
|
||||
import org.meshtastic.core.ui.util.rememberLocationPermissionState
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
|
||||
import org.meshtastic.feature.map.MapBounds
|
||||
import org.meshtastic.feature.map.MapNodePolicy
|
||||
import org.meshtastic.feature.map.TRACK_STOP_RADIUS_METERS
|
||||
import org.meshtastic.feature.map.component.ClusterMemberEntry
|
||||
import org.meshtastic.feature.map.component.ClusterMembersDialog
|
||||
import org.meshtastic.feature.map.component.CustomMapLayersSheet
|
||||
@@ -186,6 +205,7 @@ import org.meshtastic.feature.map.component.EditWaypointDialog
|
||||
import org.meshtastic.feature.map.component.MapButton
|
||||
import org.meshtastic.feature.map.component.MapControlsOverlay
|
||||
import org.meshtastic.feature.map.component.MapFilterSheet
|
||||
import org.meshtastic.feature.map.component.MeshMapFitPadding
|
||||
import org.meshtastic.feature.map.component.NodeTrackFilterMenu
|
||||
import org.meshtastic.feature.map.component.OfflineStatusBanner
|
||||
import org.meshtastic.feature.map.component.RasterOverlayToggles
|
||||
@@ -201,6 +221,7 @@ import org.meshtastic.feature.map.layers.LayerType
|
||||
import org.meshtastic.feature.map.layers.MapLayerItem
|
||||
import org.meshtastic.feature.map.layers.opacityOf
|
||||
import org.meshtastic.feature.map.layers.toPickedMapFile
|
||||
import org.meshtastic.feature.map.mergeStationaryRuns
|
||||
import org.meshtastic.feature.map.terrain.MapterhornEndpoints
|
||||
import org.meshtastic.feature.map.tiles.mapAttributionText
|
||||
import org.meshtastic.feature.map.tracerouteNodeSelection
|
||||
@@ -250,6 +271,26 @@ sealed interface GoogleMapMode {
|
||||
private const val TRACEROUTE_OFFSET_METERS = 100.0
|
||||
private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
|
||||
|
||||
/**
|
||||
* Fits [bounds] inside [MeshMapFitPadding], clear of the toolbar and the zoom pair. `newLatLngBounds` takes one padding
|
||||
* for every edge and centres the fit, so it fits the padded box and then moves that box to where the padding puts it.
|
||||
*/
|
||||
private fun CameraPositionState.frameInsideChrome(
|
||||
bounds: LatLngBounds,
|
||||
mapSize: IntSize,
|
||||
density: Density,
|
||||
layoutDirection: LayoutDirection,
|
||||
) = with(density) {
|
||||
val left = MeshMapFitPadding.calculateLeftPadding(layoutDirection).roundToPx()
|
||||
val right = MeshMapFitPadding.calculateRightPadding(layoutDirection).roundToPx()
|
||||
val top = MeshMapFitPadding.calculateTopPadding().roundToPx()
|
||||
val bottom = MeshMapFitPadding.calculateBottomPadding().roundToPx()
|
||||
val width = (mapSize.width - left - right).coerceAtLeast(1)
|
||||
val height = (mapSize.height - top - bottom).coerceAtLeast(1)
|
||||
move(CameraUpdateFactory.newLatLngBounds(bounds, width, height, 0))
|
||||
move(CameraUpdateFactory.scrollBy((right - left) / 2f, (bottom - top) / 2f))
|
||||
}
|
||||
|
||||
// Shared geofence overlay styling (orange, matching the fdroid flavor).
|
||||
private val GEOFENCE_OVERLAY_COLOR = Color(0xFFFF9800)
|
||||
private const val GEOFENCE_FILL_ALPHA = 0.12f
|
||||
@@ -274,6 +315,16 @@ private const val TERRAIN_HILLSHADE_Z_INDEX = -0.9f
|
||||
private val ATTRIBUTION_BOTTOM_PADDING = 4.dp
|
||||
private const val ATTRIBUTION_SCRIM_ALPHA = 0.7f
|
||||
|
||||
/** Faded rather than transparent, so the oldest point stays visible and tappable, as on the MapLibre track. */
|
||||
private const val OLDEST_TRACK_ALPHA = 0.25f
|
||||
private const val TRACK_FADE_LEVELS = 8
|
||||
private const val MAX_TRACK_MARKERS = 500
|
||||
private const val TRACK_POINT_SIZE_DP = 24f
|
||||
private const val SELECTED_TRACK_POINT_SIZE_DP = 32f
|
||||
private const val TRACK_POINT_OUTER_FRACTION = 10f / 24f
|
||||
private const val TRACK_POINT_RING_FRACTION = 4f / 24f
|
||||
private const val COORDINATE_DECIMALS = 5
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -287,6 +338,9 @@ fun MapView(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
|
||||
|
||||
// Collected here, not in a sheet: basemap selection and network layers report errors while no sheet is open.
|
||||
LaunchedEffect(mapViewModel) { mapViewModel.errorFlow.collectLatest { context.showToast(it) } }
|
||||
|
||||
// --- Location permissions ---
|
||||
val locationPermission = rememberLocationPermissionState()
|
||||
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
|
||||
@@ -383,6 +437,7 @@ fun MapView(
|
||||
try {
|
||||
cameraPositionState.animate(cameraUpdate)
|
||||
} catch (e: IllegalStateException) {
|
||||
if (e is CancellationException) currentCoroutineContext().ensureActive()
|
||||
Logger.d { "Error animating camera to location: ${e.message}" }
|
||||
}
|
||||
}
|
||||
@@ -419,7 +474,12 @@ fun MapView(
|
||||
|
||||
val filteredNodes = MapNodePolicy.visibleNodes(allNodes, mapFilterState, nowSeconds, ourNodeInfo?.num)
|
||||
|
||||
LaunchedEffect(mode, cameraInitialization, isMapLoaded, filteredNodes) {
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
var mapSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
LaunchedEffect(mode, cameraInitialization, isMapLoaded, filteredNodes, mapSize) {
|
||||
// The fit is sized to the map, so it waits for the first layout.
|
||||
if (mapSize == IntSize.Zero) return@LaunchedEffect
|
||||
if (
|
||||
mode is GoogleMapMode.Main &&
|
||||
cameraInitialization == CameraInitialization.FitNodes &&
|
||||
@@ -427,17 +487,14 @@ fun MapView(
|
||||
filteredNodes.isNotEmpty()
|
||||
) {
|
||||
val points = filteredNodes.map { it.position.toLatLng() }
|
||||
val cameraUpdate =
|
||||
if (points.size == 1) {
|
||||
CameraUpdateFactory.newLatLngZoom(points.first(), 12f)
|
||||
} else {
|
||||
// Shared with the MapLibre map, which pads a degenerate box rather than handing the camera
|
||||
// something it cannot fit to.
|
||||
val bounds = MapBounds.aroundNodes(filteredNodes)?.toLatLngBounds()
|
||||
if (bounds == null) return@LaunchedEffect
|
||||
CameraUpdateFactory.newLatLngBounds(bounds, 80)
|
||||
}
|
||||
cameraPositionState.move(cameraUpdate)
|
||||
if (points.size == 1) {
|
||||
cameraPositionState.move(CameraUpdateFactory.newLatLngZoom(points.first(), 12f))
|
||||
} else {
|
||||
// Shared with the MapLibre map, which pads a degenerate box rather than handing the camera
|
||||
// something it cannot fit to.
|
||||
val bounds = MapBounds.aroundNodes(filteredNodes)?.toLatLngBounds() ?: return@LaunchedEffect
|
||||
cameraPositionState.frameInsideChrome(bounds, mapSize, density, layoutDirection)
|
||||
}
|
||||
mapViewModel.onInitialNodeBoundsApplied()
|
||||
}
|
||||
}
|
||||
@@ -462,12 +519,14 @@ fun MapView(
|
||||
val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT
|
||||
|
||||
// --- Mode-specific data ---
|
||||
// Node track: apply time filter
|
||||
// Node track: apply time filter, and drop reports with no fix so toLatLng never draws them at 0,0
|
||||
val sortedTrackPositions =
|
||||
if (mode is GoogleMapMode.NodeTrack) {
|
||||
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
|
||||
remember(mode.positions, lastHeardTrackFilter) {
|
||||
mode.positions.filter { lastHeardTrackFilter.includes(it.time, nowSeconds) }.sortedBy { it.time }
|
||||
mode.positions
|
||||
.filter { it.hasFix() && lastHeardTrackFilter.includes(it.time, nowSeconds) }
|
||||
.sortedBy { it.time }
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
@@ -554,6 +613,8 @@ fun MapView(
|
||||
cameraPositionState.animate(cameraUpdate)
|
||||
hasCentered = true
|
||||
} catch (e: IllegalStateException) {
|
||||
if (e is CancellationException) currentCoroutineContext().ensureActive()
|
||||
// Reached for a user gesture interrupting animate() too: that cancels the animation, not this effect.
|
||||
Logger.d { "Error centering track map: ${e.message}" }
|
||||
}
|
||||
}
|
||||
@@ -565,6 +626,7 @@ fun MapView(
|
||||
try {
|
||||
cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng()))
|
||||
} catch (e: IllegalStateException) {
|
||||
if (e is CancellationException) currentCoroutineContext().ensureActive()
|
||||
Logger.d { "Error animating to selected position: ${e.message}" }
|
||||
}
|
||||
}
|
||||
@@ -590,6 +652,7 @@ fun MapView(
|
||||
cameraPositionState.animate(cameraUpdate)
|
||||
hasCentered = true
|
||||
} catch (e: IllegalStateException) {
|
||||
if (e is CancellationException) currentCoroutineContext().ensureActive()
|
||||
Logger.d { "Error centering traceroute overlay: ${e.message}" }
|
||||
}
|
||||
}
|
||||
@@ -625,7 +688,7 @@ fun MapView(
|
||||
Box(modifier = modifier) {
|
||||
GoogleMap(
|
||||
mapColorScheme = mapColorScheme,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize().onSizeChanged { mapSize = it },
|
||||
cameraPositionState = cameraPositionState,
|
||||
uiSettings =
|
||||
MapUiSettings(
|
||||
@@ -643,7 +706,11 @@ fun MapView(
|
||||
mapType = effectiveGoogleMapType,
|
||||
isMyLocationEnabled = isLocationTrackingEnabled && locationPermission.isGranted,
|
||||
),
|
||||
onMapLoaded = { isMapLoaded = true },
|
||||
onMapLoaded = {
|
||||
isMapLoaded = true
|
||||
// The store-screenshot capture waits for this tag instead of a fixed delay.
|
||||
Logger.withTag("MapDrawn").d { "tiles drawn" }
|
||||
},
|
||||
onMapClick = { latLng ->
|
||||
if (isMainMode && boxAuthoringDraft != null) {
|
||||
val first = boxAuthoringFirstCorner
|
||||
@@ -1074,6 +1141,7 @@ fun MapView(
|
||||
cameraPositionState.animate(CameraUpdateFactory.newCameraPosition(newCameraPosition))
|
||||
Logger.d { "Oriented map to north" }
|
||||
} catch (e: IllegalStateException) {
|
||||
if (e is CancellationException) currentCoroutineContext().ensureActive()
|
||||
Logger.d { "Error orienting map to north: ${e.message}" }
|
||||
}
|
||||
}
|
||||
@@ -1203,21 +1271,19 @@ fun MapView(
|
||||
}
|
||||
}
|
||||
|
||||
private const val SECONDS_PER_MINUTE = 60L
|
||||
private const val MILLIS_PER_SECOND = 1_000L
|
||||
|
||||
@Composable
|
||||
private fun rememberRelativeTimeBucket(): Long {
|
||||
val buckets = remember { relativeTimeBuckets() }
|
||||
return buckets.collectAsStateWithLifecycle(initialValue = nowSeconds / SECONDS_PER_MINUTE).value
|
||||
return buckets.collectAsStateWithLifecycle(initialValue = nowSeconds / TimeConstants.SECONDS_PER_MINUTE).value
|
||||
}
|
||||
|
||||
internal fun relativeTimeBuckets(now: () -> Long = { nowSeconds }): Flow<Long> = flow {
|
||||
while (true) {
|
||||
val currentSeconds = now()
|
||||
emit(currentSeconds / SECONDS_PER_MINUTE)
|
||||
val secondsUntilNextMinute = SECONDS_PER_MINUTE - currentSeconds.mod(SECONDS_PER_MINUTE)
|
||||
delay(secondsUntilNextMinute * MILLIS_PER_SECOND)
|
||||
emit(currentSeconds / TimeConstants.SECONDS_PER_MINUTE)
|
||||
val secondsUntilNextMinute =
|
||||
TimeConstants.SECONDS_PER_MINUTE - currentSeconds.mod(TimeConstants.SECONDS_PER_MINUTE)
|
||||
delay(secondsUntilNextMinute * TimeConstants.MS_PER_SEC)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1358,12 +1424,13 @@ private fun WaypointGeofenceOverlay(waypoint: Waypoint) {
|
||||
// region --- Node Track Overlay ---
|
||||
|
||||
/**
|
||||
* Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from
|
||||
* transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a
|
||||
* [TripOrigin] dot with an info-window on tap.
|
||||
* Renders the position track polyline and markers inside a [GoogleMap] content scope. Consecutive fixes within
|
||||
* [TRACK_STOP_RADIUS_METERS] of each other draw as one point, the newest fix of their run. Markers fade from faint
|
||||
* (oldest) to opaque (newest). The newest point shows the node's [NodeChip]; older points show a ring with an
|
||||
* info-window on tap. Beyond the newest [MAX_TRACK_MARKERS] points the track is drawn by the line alone.
|
||||
*
|
||||
* When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and
|
||||
* elevated z-index. Tapping a marker invokes [onPositionSelect] for list synchronization.
|
||||
* When [selectedPositionTime] falls within a point's run, that marker is highlighted with the primary color and
|
||||
* elevated z-index. Tapping a marker invokes [onPositionSelect] with its fix's time for list synchronization.
|
||||
*/
|
||||
@OptIn(MapsComposeExperimentalApi::class)
|
||||
@Composable
|
||||
@@ -1378,26 +1445,35 @@ private fun NodeTrackOverlay(
|
||||
) {
|
||||
val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
|
||||
val activeNodeZIndex = if (isHighPriority) 5f else 4f
|
||||
val trackColor = Color(focusedNode.colors.second)
|
||||
val selectedColor = MaterialTheme.colorScheme.primary
|
||||
val density = LocalDensity.current.density
|
||||
|
||||
sortedPositions.forEachIndexed { index, position ->
|
||||
key(position.time) {
|
||||
// Every point shares one of a few prebuilt icons. A composable rendered to a bitmap per point runs on the main
|
||||
// thread, and a track holds up to DEFAULT_MAX_LOGS points.
|
||||
val pointIcons =
|
||||
remember(trackColor, density) {
|
||||
List(TRACK_FADE_LEVELS) { level ->
|
||||
trackPointIcon(trackColor.copy(alpha = trackFadeAlpha(level)), TRACK_POINT_SIZE_DP, density)
|
||||
}
|
||||
}
|
||||
val selectedIcon =
|
||||
remember(selectedColor, density) { trackPointIcon(selectedColor, SELECTED_TRACK_POINT_SIZE_DP, density) }
|
||||
val pointTitle = stringResource(Res.string.position)
|
||||
val pointDescription = stringResource(Res.string.track_point)
|
||||
val runs = remember(sortedPositions) { mergeStationaryRuns(sortedPositions) }
|
||||
// Every marker is added on the main thread, so only the newest points and the selected one get one.
|
||||
val firstMarkerIndex = (runs.size - MAX_TRACK_MARKERS).coerceAtLeast(0)
|
||||
|
||||
runs.forEachIndexed { index, run ->
|
||||
val position = run.position
|
||||
val isSelected = selectedPositionTime?.let(run::covers) == true
|
||||
if (index < firstMarkerIndex && !isSelected) return@forEachIndexed
|
||||
// Keyed on the run's start: its newest fix changes while the node stays put.
|
||||
key(run.firstTime) {
|
||||
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
|
||||
val alpha =
|
||||
if (sortedPositions.size > 1) {
|
||||
index.toFloat() / (sortedPositions.size.toFloat() - 1)
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
val isSelected = position.time == selectedPositionTime
|
||||
val color =
|
||||
if (isSelected) {
|
||||
selectedColor
|
||||
} else {
|
||||
Color(focusedNode.colors.second).copy(alpha = alpha)
|
||||
}
|
||||
|
||||
if (index == sortedPositions.lastIndex) {
|
||||
if (index == runs.lastIndex) {
|
||||
MarkerComposable(
|
||||
state = markerState,
|
||||
zIndex = activeNodeZIndex,
|
||||
@@ -1410,44 +1486,62 @@ private fun NodeTrackOverlay(
|
||||
NodeChip(node = focusedNode)
|
||||
}
|
||||
} else {
|
||||
MarkerInfoWindowComposable(
|
||||
val level = trackFadeLevel(index, runs.lastIndex)
|
||||
MarkerInfoWindow(
|
||||
state = markerState,
|
||||
title = stringResource(Res.string.position),
|
||||
contentDescription = pointDescription,
|
||||
icon = if (isSelected) selectedIcon else pointIcons[level],
|
||||
title = pointTitle,
|
||||
snippet = formatAgo(position.time),
|
||||
zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha,
|
||||
zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + trackFadeAlpha(level),
|
||||
onClick = {
|
||||
onPositionSelect?.invoke(position.time)
|
||||
false // Allow default info window behavior
|
||||
},
|
||||
infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.TripOrigin,
|
||||
contentDescription = stringResource(Res.string.track_point),
|
||||
tint = color,
|
||||
modifier = if (isSelected) Modifier.size(32.dp) else Modifier,
|
||||
)
|
||||
PositionInfoWindowContent(position = position, displayUnits = displayUnits)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient polyline segments
|
||||
if (sortedPositions.size > 1) {
|
||||
val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
|
||||
segments.forEachIndexed { index, segmentPoints ->
|
||||
val alpha = index.toFloat() / (segments.size.toFloat() - 1)
|
||||
Polyline(
|
||||
points = segmentPoints.map { it.toLatLng() },
|
||||
jointType = JointType.ROUND,
|
||||
color = Color(focusedNode.colors.second).copy(alpha = alpha),
|
||||
width = 8f,
|
||||
zIndex = 0.6f,
|
||||
)
|
||||
}
|
||||
if (runs.size > 1) {
|
||||
val points = remember(runs) { runs.map { it.position.toLatLng() } }
|
||||
// A span without a segment count covers only the first segment.
|
||||
val spans =
|
||||
remember(trackColor, points.size) {
|
||||
val oldest = trackColor.copy(alpha = OLDEST_TRACK_ALPHA).toArgb()
|
||||
val gradient = StrokeStyle.gradientBuilder(oldest, trackColor.toArgb()).build()
|
||||
listOf(StyleSpan(gradient, (points.size - 1).toDouble()))
|
||||
}
|
||||
Polyline(points = points, spans = spans, jointType = JointType.ROUND, width = 8f, zIndex = 0.6f)
|
||||
}
|
||||
}
|
||||
|
||||
/** Buckets a point's position along the track so that appending a point changes the icon of only a few markers. */
|
||||
private fun trackFadeLevel(index: Int, lastIndex: Int): Int =
|
||||
if (lastIndex <= 0) TRACK_FADE_LEVELS - 1 else index * (TRACK_FADE_LEVELS - 1) / lastIndex
|
||||
|
||||
private fun trackFadeAlpha(level: Int): Float =
|
||||
OLDEST_TRACK_ALPHA + (1f - OLDEST_TRACK_ALPHA) * level / (TRACK_FADE_LEVELS - 1)
|
||||
|
||||
/** A ring, drawn straight to a bitmap so building it never composes. */
|
||||
private fun trackPointIcon(color: Color, sizeDp: Float, density: Float): BitmapDescriptor {
|
||||
val sizePx = (sizeDp * density).roundToInt().coerceAtLeast(1)
|
||||
val bitmap = createBitmap(sizePx, sizePx)
|
||||
val stroke = sizePx * TRACK_POINT_RING_FRACTION
|
||||
val paint =
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = stroke
|
||||
this.color = color.toArgb()
|
||||
}
|
||||
// The ring's outer edge sits inside a 24-unit box at radius 10, matching the TripOrigin glyph.
|
||||
val outerRadius = sizePx * TRACK_POINT_OUTER_FRACTION
|
||||
bitmap.applyCanvas { drawCircle(sizePx / 2f, sizePx / 2f, outerRadius - stroke / 2f, paint) }
|
||||
return BitmapDescriptorFactory.fromBitmap(bitmap)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun PositionInfoWindowContent(position: Position, displayUnits: MeasurementSystem = MeasurementSystem.METRIC) {
|
||||
@@ -1464,11 +1558,11 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: Measurem
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
PositionRow(
|
||||
label = stringResource(Res.string.latitude),
|
||||
value = "%.5f".format((position.latitude_i ?: 0) * DEG_D),
|
||||
value = NumberFormatter.format((position.latitude_i ?: 0) * DEG_D, COORDINATE_DECIMALS),
|
||||
)
|
||||
PositionRow(
|
||||
label = stringResource(Res.string.longitude),
|
||||
value = "%.5f".format((position.longitude_i ?: 0) * DEG_D),
|
||||
value = NumberFormatter.format((position.longitude_i ?: 0) * DEG_D, COORDINATE_DECIMALS),
|
||||
)
|
||||
PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view.toString())
|
||||
PositionRow(
|
||||
@@ -1478,7 +1572,7 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: Measurem
|
||||
PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits))
|
||||
PositionRow(
|
||||
label = stringResource(Res.string.heading),
|
||||
value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG),
|
||||
value = "${NumberFormatter.format((position.ground_track ?: 0) * HEADING_DEG, 0)}°",
|
||||
)
|
||||
PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime())
|
||||
}
|
||||
@@ -1554,20 +1648,19 @@ private fun offsetPolyline(
|
||||
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
|
||||
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
|
||||
|
||||
val headings =
|
||||
headingPoints.mapIndexed { index, _ ->
|
||||
when (index) {
|
||||
0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
|
||||
val headings = headingPoints.mapIndexed { index, _ ->
|
||||
when (index) {
|
||||
0 -> SphericalUtil.computeHeading(headingPoints[0], headingPoints[1])
|
||||
|
||||
headingPoints.lastIndex ->
|
||||
SphericalUtil.computeHeading(
|
||||
headingPoints[headingPoints.lastIndex - 1],
|
||||
headingPoints[headingPoints.lastIndex],
|
||||
)
|
||||
headingPoints.lastIndex ->
|
||||
SphericalUtil.computeHeading(
|
||||
headingPoints[headingPoints.lastIndex - 1],
|
||||
headingPoints[headingPoints.lastIndex],
|
||||
)
|
||||
|
||||
else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
|
||||
}
|
||||
else -> SphericalUtil.computeHeading(headingPoints[index - 1], headingPoints[index + 1])
|
||||
}
|
||||
}
|
||||
|
||||
return points.mapIndexed { index, point ->
|
||||
val heading = headings[index.coerceIn(0, headings.lastIndex)]
|
||||
@@ -1594,7 +1687,7 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, opacity: Float, mapViewMode
|
||||
val layer =
|
||||
try {
|
||||
val dataLayer =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
// Buffered because the KMZ sniff marks and resets the stream before the parser reads it.
|
||||
BufferedInputStream(ByteArrayInputStream(bytes)).use { stream ->
|
||||
parseMapLayer(layerItem.layerType, stream)
|
||||
|
||||
@@ -61,7 +61,6 @@ import org.meshtastic.app.map.tiles.RasterTileProvider
|
||||
import org.meshtastic.app.map.tiles.toRasterBasemap
|
||||
import org.meshtastic.core.common.util.LocaleUnitsProvider
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeAddress
|
||||
import org.meshtastic.core.network.repository.NetworkRepository
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
@@ -72,6 +71,9 @@ import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.RadioController
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.url_http_localhost_only
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.feature.map.layers.LayerOpacityStore
|
||||
@@ -89,6 +91,8 @@ import org.meshtastic.feature.map.tiles.CustomTileProviderSaveResult
|
||||
import org.meshtastic.feature.map.tiles.MapTileCatalogue
|
||||
import org.meshtastic.feature.map.tiles.RasterOverlaySource
|
||||
import org.meshtastic.feature.map.tiles.RasterTileSpec
|
||||
import org.meshtastic.feature.map.tiles.isCleartextPermitted
|
||||
import org.meshtastic.feature.map.tiles.isRefusedCleartextTileUrl
|
||||
import org.meshtastic.feature.map.tiles.isValidTileUrlTemplate
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
@@ -142,27 +146,13 @@ class MapViewModel(
|
||||
private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get<Int>("waypointId"))
|
||||
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
|
||||
|
||||
// Injected by the map provider because this SavedStateHandle is not the Navigation 3 entry's route state.
|
||||
private val sitePlannerRequestState = SitePlannerRequestState(nodeRepository.nodeDBbyNum)
|
||||
val sitePlannerRequest: StateFlow<Node?> =
|
||||
sitePlannerRequestState.request.stateInWhileSubscribed(initialValue = null)
|
||||
|
||||
fun setSitePlannerNodeNum(nodeNum: Int?) {
|
||||
sitePlannerRequestState.setNodeNum(nodeNum)
|
||||
}
|
||||
|
||||
fun consumeSitePlannerRequest(nodeNum: Int) {
|
||||
sitePlannerRequestState.consume(nodeNum)
|
||||
}
|
||||
|
||||
fun setWaypointId(id: Int?) {
|
||||
if (_selectedWaypointId.value != id) {
|
||||
_selectedWaypointId.value = id
|
||||
if (id != null) {
|
||||
viewModelScope.launch {
|
||||
val wpMap = waypoints.first { it.containsKey(id) }
|
||||
wpMap[id]?.let { packet ->
|
||||
val waypoint = packet.waypoint!!
|
||||
wpMap[id]?.waypoint?.let { waypoint ->
|
||||
val latLng =
|
||||
LatLng(
|
||||
(waypoint.latitude_i ?: 0) / WAYPOINT_COORD_SCALE,
|
||||
@@ -345,6 +335,9 @@ class MapViewModel(
|
||||
if (config != null) {
|
||||
if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
|
||||
Logger.withTag("MapViewModel").w("Attempted to select an invalid custom tile URL template")
|
||||
if (config.urlTemplate.isRefusedCleartextTileUrl()) {
|
||||
viewModelScope.launch { _errorFlow.emit(getStringSuspend(Res.string.url_http_localhost_only)) }
|
||||
}
|
||||
clearCurrentTileProvider()
|
||||
_selectedRasterBasemapId.value = null
|
||||
_selectedGoogleMapType.value = MapType.NORMAL
|
||||
@@ -443,8 +436,7 @@ class MapViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidTileUrlTemplate(urlTemplate: String): Boolean =
|
||||
urlTemplate.isValidTileUrlTemplate(requireHttps = false)
|
||||
private fun isValidTileUrlTemplate(urlTemplate: String): Boolean = urlTemplate.isValidTileUrlTemplate()
|
||||
|
||||
/** What to restore once the network returns from an auto-switch; null when nothing has been auto-switched. */
|
||||
private data class OfflineAutoSwitchState(val rasterBasemapId: String?, val googleMapType: MapType)
|
||||
@@ -630,23 +622,22 @@ class MapViewModel(
|
||||
|
||||
_terrainDownloadRegionId.value = regionId
|
||||
_terrainDownloadState.value = null
|
||||
terrainDownloadJob =
|
||||
viewModelScope.launch {
|
||||
val store = terrainStoreForRegion(regionId)
|
||||
val bounds =
|
||||
GeoBounds(
|
||||
south = region.southLat,
|
||||
west = region.westLon,
|
||||
north = region.northLat,
|
||||
east = region.eastLon,
|
||||
)
|
||||
val maxZoom = TerrainDownloadPlanner.maxZoomFitting(bounds, TerrainRegionExtractor.MAX_TILES)
|
||||
// flowOn: the extractor does blocking per-tile HTTP on its collector's dispatcher.
|
||||
TerrainRegionExtractor(store).download(bounds, maxZoom).flowOn(dispatchers.io).collect { state ->
|
||||
_terrainDownloadState.value = state
|
||||
if (state is TerrainDownloadState.Complete) attachTerrain(region, state, store)
|
||||
}
|
||||
terrainDownloadJob = viewModelScope.launch {
|
||||
val store = terrainStoreForRegion(regionId)
|
||||
val bounds =
|
||||
GeoBounds(
|
||||
south = region.southLat,
|
||||
west = region.westLon,
|
||||
north = region.northLat,
|
||||
east = region.eastLon,
|
||||
)
|
||||
val maxZoom = TerrainDownloadPlanner.maxZoomFitting(bounds, TerrainRegionExtractor.MAX_TILES)
|
||||
// flowOn: the extractor does blocking per-tile HTTP on its collector's dispatcher.
|
||||
TerrainRegionExtractor(store).download(bounds, maxZoom).flowOn(dispatchers.io).collect { state ->
|
||||
_terrainDownloadState.value = state
|
||||
if (state is TerrainDownloadState.Complete) attachTerrain(region, state, store)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun attachTerrain(
|
||||
@@ -711,8 +702,7 @@ class MapViewModel(
|
||||
selectedWaypointId.value?.let { wpId ->
|
||||
viewModelScope.launch {
|
||||
val wpMap = waypoints.first { it.containsKey(wpId) }
|
||||
wpMap[wpId]?.let { packet ->
|
||||
val waypoint = packet.waypoint!!
|
||||
wpMap[wpId]?.waypoint?.let { waypoint ->
|
||||
val latLng =
|
||||
LatLng(
|
||||
(waypoint.latitude_i ?: 0) / WAYPOINT_COORD_SCALE,
|
||||
@@ -765,6 +755,7 @@ class MapViewModel(
|
||||
if (selection.customTileUrl != null) googleMapsPrefs.setSelectedCustomTileUrl(null)
|
||||
} else {
|
||||
_selectedRasterBasemapId.value = null
|
||||
if (resolvedSelection.refusedCleartextSource) reportRefusedCleartextSource()
|
||||
if (resolvedSelection.canDiscardMissingSelection) {
|
||||
if (selectedProviderId != null) mapTileProviderPrefs.setSelectedCustomTileProviderId(null)
|
||||
if (selection.customTileUrl != null) googleMapsPrefs.setSelectedCustomTileUrl(null)
|
||||
@@ -783,6 +774,14 @@ class MapViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits for a collector: this runs from init, before the map collects, and the selection is cleared next. */
|
||||
private fun reportRefusedCleartextSource() {
|
||||
viewModelScope.launch {
|
||||
_errorFlow.subscriptionCount.first { it > 0 }
|
||||
_errorFlow.emit(getStringSuspend(Res.string.url_http_localhost_only))
|
||||
}
|
||||
}
|
||||
|
||||
fun addMapLayer(picked: PickedMapFile) = mapLayersManager.addMapLayer(picked)
|
||||
|
||||
fun addNetworkMapLayer(name: String, url: String) {
|
||||
@@ -853,24 +852,30 @@ internal fun List<CustomTileProviderConfig>.findLegacyCustomTileProvider(
|
||||
internal data class PersistedCustomTileSelection(
|
||||
val provider: CustomTileProviderConfig?,
|
||||
val canDiscardMissingSelection: Boolean,
|
||||
val refusedCleartextSource: Boolean = false,
|
||||
)
|
||||
|
||||
internal fun List<CustomTileProviderConfig>.resolvePersistedCustomTileSelection(
|
||||
selectedProviderId: String?,
|
||||
legacySource: String?,
|
||||
providerLoadSuccessful: Boolean,
|
||||
cleartextPermitted: (host: String) -> Boolean = ::isCleartextPermitted,
|
||||
): PersistedCustomTileSelection {
|
||||
val provider =
|
||||
val candidates =
|
||||
listOfNotNull(findSelectedCustomTileProvider(selectedProviderId), findLegacyCustomTileProvider(legacySource))
|
||||
.firstOrNull { it.hasValidGoogleTileSource() }
|
||||
val provider = candidates.firstOrNull { it.hasValidGoogleTileSource(cleartextPermitted) }
|
||||
return PersistedCustomTileSelection(
|
||||
provider = provider,
|
||||
canDiscardMissingSelection = provider == null && providerLoadSuccessful,
|
||||
refusedCleartextSource =
|
||||
provider == null &&
|
||||
candidates.any { !it.isLocal && it.urlTemplate.isRefusedCleartextTileUrl(cleartextPermitted) },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun CustomTileProviderConfig.hasValidGoogleTileSource(): Boolean =
|
||||
isLocal || urlTemplate.isValidTileUrlTemplate(requireHttps = false)
|
||||
internal fun CustomTileProviderConfig.hasValidGoogleTileSource(
|
||||
cleartextPermitted: (host: String) -> Boolean = ::isCleartextPermitted,
|
||||
): Boolean = isLocal || urlTemplate.isValidTileUrlTemplate(cleartextPermitted)
|
||||
|
||||
private fun GoogleCameraPosition.toCameraPosition() = CameraPosition(LatLng(targetLat, targetLng), zoom, tilt, bearing)
|
||||
|
||||
|
||||
-5
@@ -22,16 +22,13 @@ import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.app.map.MapViewModel
|
||||
import org.meshtastic.app.map.importMbTiles
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.map.component.CustomTileProviderManager
|
||||
import org.meshtastic.feature.map.layers.getFileName
|
||||
import java.io.File
|
||||
@@ -65,8 +62,6 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { mapViewModel.errorFlow.collectLatest { context.showToast(it) } }
|
||||
|
||||
CustomTileProviderManager(
|
||||
providers = providers,
|
||||
onAdd = mapViewModel::addCustomTileProvider,
|
||||
|
||||
@@ -25,8 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.google.android.gms.maps.model.LatLng
|
||||
import com.google.maps.android.compose.MapsComposeExperimentalApi
|
||||
import com.google.maps.android.compose.Marker
|
||||
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
|
||||
import com.google.maps.android.compose.MarkerComposable
|
||||
import com.google.maps.android.compose.rememberUpdatedMarkerState
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.app.map.convertIntToEmoji
|
||||
@@ -67,10 +66,6 @@ fun WaypointMarkers(
|
||||
|
||||
val iconCodePoint = waypoint.icon.waypointIconOrDefault()
|
||||
val emojiText = convertIntToEmoji(iconCodePoint)
|
||||
val icon =
|
||||
rememberComposeBitmapDescriptor(iconCodePoint) {
|
||||
Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
|
||||
}
|
||||
|
||||
// Non-visual cue: the geofence is otherwise only an orange overlay, so surface it in the marker's
|
||||
// accessible snippet for screen-reader and color-challenged users.
|
||||
@@ -88,9 +83,9 @@ fun WaypointMarkers(
|
||||
val cleanName = waypoint.name.replace('\n', ' ').replace('\b', ' ')
|
||||
val title = if (waypoint.isLocked) "${convertIntToEmoji(LOCK)} $cleanName" else cleanName
|
||||
|
||||
Marker(
|
||||
MarkerComposable(
|
||||
iconCodePoint,
|
||||
state = markerState,
|
||||
icon = icon,
|
||||
title = title,
|
||||
snippet = snippet,
|
||||
visible = true,
|
||||
@@ -106,7 +101,9 @@ fun WaypointMarkers(
|
||||
else -> onDeleteWaypointRequest(waypoint)
|
||||
}
|
||||
},
|
||||
)
|
||||
) {
|
||||
Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
package org.meshtastic.app.map.discovery
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.meshtastic.core.ui.util.DiscoveryMapNode
|
||||
|
||||
/** Flavor-unified entry point for the discovery map. Google Maps implementation. */
|
||||
@Composable
|
||||
@NonRestartableComposable
|
||||
fun DiscoveryMap(
|
||||
userLatitude: Double,
|
||||
userLongitude: Double,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* 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.app.map.node
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.app.map.GoogleMapMode
|
||||
import org.meshtastic.app.map.MapView
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.feature.map.node.NodeMapViewModel
|
||||
|
||||
@Composable
|
||||
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
|
||||
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
|
||||
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = node?.user?.long_name ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
MapView(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ fun NodeTrackMap(
|
||||
modifier: Modifier = Modifier,
|
||||
selectedPositionTime: Int? = null,
|
||||
onPositionSelect: ((Int) -> Unit)? = null,
|
||||
// Accepted for the shared seam and ignored: Google Maps draws its own attribution, in MapView.
|
||||
@Suppress("UNUSED_PARAMETER") showAttribution: Boolean = true,
|
||||
) {
|
||||
val vm = koinViewModel<NodeMapViewModel>()
|
||||
vm.setDestNum(destNum)
|
||||
|
||||
+2
-2
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import java.io.IOException
|
||||
import java.util.zip.GZIPInputStream
|
||||
import kotlin.uuid.Uuid
|
||||
@@ -121,7 +122,7 @@ internal class OfflineRegionExtractor(private val store: OfflineRegionStore) {
|
||||
maxZoom = zoomRange.last,
|
||||
tileCount = tiles.size.toLong(),
|
||||
byteSize = archiveFile.length(),
|
||||
createdAtEpochSeconds = System.currentTimeMillis() / MILLIS_PER_SECOND,
|
||||
createdAtEpochSeconds = nowSeconds,
|
||||
)
|
||||
.also { store.add(it) }
|
||||
} catch (e: IOException) {
|
||||
@@ -171,7 +172,6 @@ internal class OfflineRegionExtractor(private val store: OfflineRegionStore) {
|
||||
const val MAX_REGIONS = 10
|
||||
const val MAX_TOTAL_BYTES = 300L * 1024 * 1024
|
||||
private const val PROGRESS_STRIDE = 10
|
||||
private const val MILLIS_PER_SECOND = 1_000L
|
||||
|
||||
/** Both the Protomaps build and the MVT layers it packages (OpenStreetMap) require attribution. */
|
||||
const val ATTRIBUTION = "© OpenStreetMap contributors, © Protomaps"
|
||||
|
||||
Loaded 100 of 2202 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user