# Run Meshtastic Android

> Run, launch, drive, and screenshot the Meshtastic app — the Compose Desktop app via hot reload (semantic clicks, live reload, window screenshots) or the Android app on an emulator (scripted deeplink bring-up, uiautomator taps, screencap). Use when asked to run the app, verify a UI change in the real app, take a screenshot, or exercise a flow end to end against a simulated radio.

- Skill: `meshtastic/run-meshtastic-android` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add meshtastic/run-meshtastic-android`
- Raw SKILL.md: https://api.skillmd.com/api/skills/meshtastic/run-meshtastic-android/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: meshtastic (https://skillmd.com/u/meshtastic)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/meshtastic/run-meshtastic-android

---


# Run Meshtastic (Desktop & Emulator)

Two binaries, two drivers, one simulated radio. All paths are relative to the repo
root. Both drivers are Python 3, stdlib only, and print `--- <cmd> done ---` per
step on stderr.

- **Desktop** (`:desktopApp`, Compose/JVM, runs on this machine): launch with the
  hot-reload run task, drive through `.claude/skills/run-meshtastic-android/driver.py`,
  which speaks MCP JSON-RPC to `:desktopApp:hotMcpServer` — semantic tree, clicks
  by node id, `reload` (recompile + hot-swap), window screenshots.
- **Emulator** (`:androidApp` fdroid debug): drive through
  `.claude/skills/run-meshtastic-android/driver_emulator.py` — scripted deeplink
  bring-up, uiautomator-based taps, screencap.
- **Radio**: neither app does much without one. `mcp__meshtastic__replay_start`
  (meshtastic MCP) serves a simulated Meshtastic TCP radio; the desktop app reaches
  it at `127.0.0.1:<port>`, an AVD at `10.0.2.2:<port>`. One client per session —
  run the desktop and emulator against **different ports** (e.g. 4403 and 4404).

## Prerequisites

- Gradle runs go through the machine-wide queue: `~/.claude/bin/gradle-queue`.
  Everything after its `--` is **Gradle arguments** — it runs `./gradlew` itself
  (`gradle-queue -- ./gradlew tasks` fails with `Task './gradlew' not found`).
- The JetBrains 25 JDK Gradle provisioned at
  `~/.gradle/jdks/jetbrains_s_r_o_-25-*/…/Contents/Home` (the drivers find it themselves).
- Emulator leg: a running AVD (`adb devices`) with the fdroid debug build installed
  (`./gradlew :androidApp:installFdroidDebug` via the queue if missing).
- A simulated radio, e.g. `replay_start(source="meshcon", sim_nodes=30, port=4403,
  rate=2, loop=true, sim_profile={"traceroute_pairs_per_hour": 0})` — mute the
  traceroutes or their modals bury whatever you are testing.

## Run: Desktop (agent path)

Kill stray instances first — two apps fight over the pid file and the MCP server
reports `connected:false` forever:

```bash
pgrep -fl "MainKt|devtools.Main"   # kill any hits before launching
```

Launch (the Nix dev shell's Darwin stdenv breaks the MapLibre FFI — strip it):

```bash
env -u DEVELOPER_DIR -u SDKROOT -u CC -u CXX -u LD -u AR -u NM -u RANLIB -u STRIP -u NIX_CC \
  JAVA_HOME=$(ls -d ~/.gradle/jdks/jetbrains_s_r_o_-25-*/*/Contents/Home | tail -1) \
  PATH=/usr/bin:/bin:/usr/sbin:/sbin \
  ~/.claude/bin/gradle-queue -- :desktopApp:hotRunAsync
```

`BUILD SUCCESSFUL` + `desktopApp/build/run/main/main.pid` on disk means the app is up.

Drive it. Each driver invocation spawns a fresh `hotMcpServer`, auto-waits for it to
attach (asynchronous — the driver polls `status` for you), runs the commands in
order, and exits:

```bash
python3 .claude/skills/run-meshtastic-android/driver.py tree            # semantic tree (JSON, node ids)
python3 .claude/skills/run-meshtastic-android/driver.py click=170 sleep=1.5 tree
python3 .claude/skills/run-meshtastic-android/driver.py raise ss=/tmp/app.png
python3 .claude/skills/run-meshtastic-android/driver.py reload          # recompile + hot-swap edits
```

Run `driver.py` with no arguments for the full command list (`type=NODEID:TEXT`,
`scroll_to=NODEID:IDX`, `restart`, `err`, `logs`, …). `tools` prints the server's
live tool schemas if they've drifted.

**Verified flow** (connect to a sim and see its mesh): nav-rail tabs are semantic
`Tab` nodes — `Connect` opened via `click=<its id from tree>`, then the `Network`
radio button, then the device row for `127.0.0.1` under Recent Network Devices.
The sim's `replay_status` flips to `connected:true` within seconds and the Nodes
tab fills with the sim's mesh (`RPLY Replay Observer`, …).

The connection card can sit on "Reconnecting…" while packets already flow — the
label lags the config download. Trust `replay_status` and the Nodes list, not the
card text.

**Screenshots capture the window's on-screen region**, so the window must be
frontmost: always `raise` before `ss`. If `ss` shows your terminal, that's why.

`hotMcpServer` and `reload` compile **outside** gradle-queue (a long-lived stdio
server can't hold a slot) — check `~/.claude/bin/gradle-queue --status` before a
`reload` if other sessions may be building, and keep those runs short.

### Desktop deeplink launch (no clicking — but no hot reload)

The desktop app parses the same Meshtastic deeplink URIs from its **program args**
(`Main.kt` accepts `meshtastic://` and `https://meshtastic.org/...`), so a connected
app is one command:

```bash
env -u DEVELOPER_DIR -u SDKROOT -u CC -u CXX -u LD -u AR -u NM -u RANLIB -u STRIP -u NIX_CC \
  JAVA_HOME=$(ls -d ~/.gradle/jdks/jetbrains_s_r_o_-25-*/*/Contents/Home | tail -1) \
  PATH=/usr/bin:/bin:/usr/sbin:/sbin \
  ~/.claude/bin/gradle-queue -- :desktopApp:run --args="https://meshtastic.org/connections?address=t127.0.0.1:4403"
```

Verified against a sim the app had never connected to before, so it is the deeplink
acting, not last-device auto-reconnect. Caveats, all observed:

- `hotRunAsync` does **not** accept `--args` (its option list: --auto, --className,
  --funName, --mainClass, --stdout/--stderr only) — deeplink launch means the plain
  `run` task, which trades away hot reload. Long driving session → `hotRunAsync` +
  the driver's click path; quick "get me a connected app" → `run --args=…`.
- `run` blocks, so it **holds a gradle-queue slot for the app's whole lifetime**.
  Keep such runs short, or other sessions' builds will queue behind your app.
- The deeplink races last-device auto-reconnect: the app can connect to its
  remembered device first, then switch to the deeplink's target a moment later —
  if the remembered device is another sim, that sim briefly shows a client too.
- No trust dialog blocked the localhost connect in testing (unlike the Android
  build, which pops one for a never-seen device).

## Run: Emulator (agent path)

Scripted bring-up only — never hand-walk onboarding or the manual-IP dialog:

```bash
python3 .claude/skills/run-meshtastic-android/driver_emulator.py -s emulator-5554 \
  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
screen showing `RPLY Replay Observer` with a **Disconnect** button, and
`replay_status` reporting `connected:true`.

Other commands: `dump`, `find=TEXT`, `tap_text=TEXT`, `tap=X,Y`, `text=`, `key=`,
`swipe=`, `launch`, `stop` — run with no arguments for the list. Default package is
`com.geeksville.mesh.fdroid.debug` (`-p` to override).

## Run (human path)

`./gradlew :desktopApp:run` (via the queue, same env hygiene) opens the window
without hot reload; Ctrl-C to stop. The emulator app is just the launcher icon —
but a debug build launched by icon lands on onboarding; the deeplink path above is
faster even for humans.

## Stopping

- Desktop: take the pid from the app's own pid file — it is a Java properties
  file (not a bare pid) and self-deletes on clean exit:

  ```bash
  kill $(sed -n 's/^pid=//p' desktopApp/build/run/main/main.pid)
  ```

  If the pid file is gone but a process lingers, `pgrep -af "MainKt|devtools.Main"`,
  check each match's path for **this** checkout, and kill that specific PID — a bare
  `pkill` on the pattern can take down another checkout's or session's app.
- Emulator: `driver_emulator.py -s <serial> stop`.
- Sim: `replay_stop`. Sessions the sim created are real user data in the app's DB;
  the app's last-selected device is now the sim — switch back on the Connect screen
  if a real radio should reconnect.

## Gotchas

- **`gradle-queue -- ./gradlew …` fails**: args after `--` go to `./gradlew`,
  which the wrapper runs itself. And piping its output (`| tail`) eats the exit
  code — check for `BUILD SUCCESSFUL` in the text, not `$?`.
- **`tap_text` matches substrings**: bare `Connect` also matches "Stop
  **Connect**ing" and "Re**connect**ing…". The driver tries exact text first;
  wait on the trust dialog's title ("Connect to this device"), not its button.
- **The MCP server attaches asynchronously** — a `tree` fired immediately after
  spawn returns "No application is currently connected". The driver auto-waits;
  if it times out, the app isn't running (or a stray instance holds the pid file).
- **`take_screenshot` needs the window visible** — `raise` first (System Events
  `AXRaise` targeting the window literally named "Meshtastic Desktop"; with two
  java processes, pid-based frontmosting picks the wrong one).
- **One client per simulated node.** Two apps pointed at the same sim don't
  queue — they fight, stealing the connection back and forth so both flap
  between Connected and Reconnecting. The desktop app holding port 4403 means
  the emulator needs its own `replay_start` on 4404.
- **`adb shell input text` can leave a trailing space**; dialogs' Add buttons
  silently no-op on it. And don't press BACK to dismiss the keyboard — it closes
  the dialog.
- **Swipe near x≈30** in lists; mid-screen swipes get eaten by embedded maps.
  Never busy-loop adb — pace with `adb shell sleep 2` or the emulator drops offline.
- The desktop app auto-reconnects to its last device on launch — it may already
  be connected to a real radio when you attach; check the Connect screen before
  assuming the sim.

## Troubleshooting

- `Task './gradlew' not found in root project` → you passed `./gradlew` after
  `gradle-queue --`; drop it.
- `BUILD FAILED in 1s` from `hotRunAsync` with slots free → read the full output;
  the queue wrapper's exit code vanishes behind pipes.
- Screenshot is your terminal → `raise` before `ss` (window wasn't frontmost).
- `connected:false` forever from `status` → stray `MainKt` from another checkout
  or worktree; `pgrep -fl MainKt`, kill, relaunch.
- Trust dialog never tapped, app stuck on dialog → older driver matched
  "Reconnecting…"; re-run `tap_text=Connect` (exact match wins now).
- UI card stuck "Reconnecting…" but sim says `connected:true` → not stuck; config
  download in progress. Check the Nodes tab for the sim's nodes.
- Connection flapping → `desktopApp/build/run/main/hotRun.stderr.txt` carries the
  transport-level story ("Handshake stall detected at Stage 1 … requesting forced
  transport restart" is the app self-recovering, not a crash). Also check that a
  second app isn't fighting for the same sim (one client per simulated node).
- A bare `status` right after spawn can report `connected:false` while the app is
  fine — the server attach is asynchronous; `wait` (or any UI command, which
  auto-waits) is the truth.

