# mob_dev
Development tooling for [Mob](https://hexdocs.pm/mob) — the BEAM-on-device mobile framework for Elixir.
[](https://hex.pm/packages/mob_dev)
## Installation
Add to your project's `mix.exs` (dev only):
```elixir
def deps do
[
{:mob_dev, "~> 0.2", only: :dev}
]
end
```
## Mix tasks
| Task | Description |
|------|-------------|
| `mix mob.new APP_NAME` | Generate a new Mob project (see `mob_new` archive) |
| `mix mob.install` | First-run setup: download OTP runtime, generate icons, write `mob.exs` |
| `mix mob.deploy` | Compile and push BEAMs to all connected devices |
| `mix mob.deploy --native` | Also build and install the native APK/iOS app |
| `mix mob.connect` | Tunnel + restart + open IEx connected to device nodes (`--name` for multiple sessions) |
| `mix mob.watch` | Auto-push BEAMs on file save |
| `mix mob.watch_stop` | Stop a running `mix mob.watch` |
| `mix mob.devices` | List connected devices and their status |
| `mix mob.push` | Hot-push only changed modules (no restart) |
| `mix mob.server` | Start the dev dashboard at `localhost:4040` |
| `mix mob.icon` | Regenerate app icons |
| `mix mob.routes` | Validate navigation destinations across the codebase |
| `mix mob.battery_bench_android` | Measure BEAM idle power draw on an Android device |
| `mix mob.battery_bench_ios` | Measure BEAM idle power draw on a physical iOS device |
## Dev dashboard (`mix mob.server`)
`mix mob.server` starts a local Phoenix server (default port 4040) with:
- **Device cards** — live status for connected Android emulators and iOS simulators, with Deploy and Update buttons per device
- **Device log panel** — streaming logcat / iOS simulator console with text filter
- **Elixir log panel** — Elixir `Logger` output forwarded from the running BEAM, with text filter
- **Watch mode toggle** — auto-push changed BEAMs on file save without running a separate terminal
- **QR code** — LAN URL for opening the dashboard on a physical device
Run with IEx for an interactive terminal alongside the dashboard:
```bash
iex -S mix mob.server
```
### Watch mode
Click **Watch** in the dashboard header or control it programmatically:
```elixir
MobDev.Server.WatchWorker.start_watching()
MobDev.Server.WatchWorker.stop_watching()
MobDev.Server.WatchWorker.status()
#=> %{active: true, nodes: [:"my_app_ios@127.0.0.1"], last_push: ~U[...]}
```
Watch events broadcast on `"watch"` PubSub topic:
```elixir
{:watch_status, :watching | :idle}
{:watch_push, %{pushed: [...], failed: [...], nodes: [...], files: [...]}}
```
## Hot-push transport (`mix mob.deploy`)
When Erlang distribution is reachable, `mix mob.deploy` hot-pushes changed BEAMs in-place via RPC — no `adb push`, no app restart. The running modules are replaced exactly like `nl/1` in IEx.
```
Pushing 14 BEAM file(s) to 2 device(s)...
Pixel_7_API_34 → pushing... ✓ (dist, no restart)
iPhone 15 Pro → pushing... ✓ (dist, no restart)
```
If dist is not reachable (first deploy, app not running), it falls back to `adb push` + restart. Mixed deploys work — one device can hot-push while another restarts.
**Requirements:** The app must call `Mob.Dist.ensure_started/1` at startup, and the cookie must match the one in `mob.exs` (default `:mob_secret`).
## Navigation validation (`mix mob.routes`)
Validates all `push_screen`, `reset_to`, and `pop_to` destinations across `lib/**/*.ex` via AST analysis. Module destinations are verified with `Code.ensure_loaded/1`.
```bash
mix mob.routes # print warnings
mix mob.routes --strict # exit non-zero (for CI)
```
```
✓ 12 navigation reference(s) valid (2 dynamic/named skipped)
# On failure:
✗ 1 unresolvable navigation destination(s):
lib/my_app/home_screen.ex:42 push_screen(socket, MyApp.SettingsScren)
Module MyApp.SettingsScren could not be loaded.
```
Dynamic destinations (`push_screen(socket, var)`) and registered name atoms (`:main`) are skipped with a note.
## Battery benchmarks
Measure BEAM idle power draw with specific tuning flags. Both tasks share the same presets and flag interface.
### Android (`mix mob.battery_bench_android`)
Deploys an APK and measures drain via the hardware charge counter (`dumpsys battery`). Reports mAh every 10 seconds.
**WiFi ADB required** — a USB cable charges the device and skews measurements.
```bash
# One-time WiFi ADB setup (while plugged in):
adb -s SERIAL tcpip 5555
adb connect PHONE_IP:5555
# then unplug
mix mob.battery_bench_android # default: Nerves-tuned BEAM, 30 min
mix mob.battery_bench_android --no-beam # baseline: no BEAM at all
mix mob.battery_bench_android --preset untuned # raw BEAM, no tuning
mix mob.battery_bench_android --flags "-sbwt none -S 1:1"
mix mob.battery_bench_android --duration 3600 --device 192.168.1.42:5555
mix mob.battery_bench_android --no-build # re-run without rebuilding
```
### iOS (`mix mob.battery_bench_ios`)
Deploys to a physical iPhone/iPad and reads battery via `ideviceinfo`. Reports mAh (if `BatteryMaxCapacity` is available) or percentage points.
**Prerequisites:** `brew install libimobiledevice`, Xcode 15+, device trusted on this Mac.
```bash
mix mob.battery_bench_ios # default: Nerves-tuned BEAM, 30 min
mix mob.battery_bench_ios --no-beam # baseline: no BEAM at all
mix mob.battery_bench_ios --preset untuned # raw BEAM, no tuning
mix mob.battery_bench_ios --flags "-sbwt none -S 1:1"
mix mob.battery_bench_ios --duration 3600 --device UDID
mix mob.battery_bench_ios --no-build # re-run without rebuilding
```
### Presets and results
| Preset | Flags | mAh/hr (Moto G) |
|--------|-------|----------------|
| No BEAM | — | ~200 |
| Nerves (default) | `-S 1:1 -SDcpu 1:1 -SDio 1 -A 1 -sbwt none` | ~202 |
| Untuned | *(none)* | ~250 |
The Nerves-tuned BEAM is essentially indistinguishable from a stock Android app at idle. The untuned BEAM costs ~25% more because schedulers spin-wait instead of sleeping.
## Working with an agent (Claude Code / LLM)
Because OTP runs on the device, an agent can connect directly to the running app via Erlang distribution and inspect or drive it programmatically — no screenshots required.
### How it works
```
Agent (Claude Code)
│
├── mix mob.connect → tunnels EPMD, connects IEx to device node
│
├── Mob.Test.* → inspect screen state, trigger taps via RPC
│ (exact state: module, assigns, render tree)
│
└── MCP tools → native UI when needed
├── adb-mcp → Android: screenshot, shell, UI inspect
└── ios-simulator-mcp → iOS: screenshot, tap, describe UI
```
### Mob.Test — preferred for agents
`Mob.Test` gives exact app state via Erlang distribution. Prefer it over screenshots whenever possible — it doesn't depend on rendering, is instantaneous, and works offline.
```elixir
node = :"my_app_ios@127.0.0.1"
# Inspection
Mob.Test.screen(node) #=> MyApp.HomeScreen
Mob.Test.assigns(node) #=> %{count: 3, user: %{name: "Alice"}, ...}
Mob.Test.find(node, "Save") #=> [{[0, 2], %{"type" => "button", ...}}]
Mob.Test.inspect(node) # full snapshot: screen + assigns + nav history + tree
# Tap a button by tag atom (from on_tap: {self(), :save} in render/1)
Mob.Test.tap(node, :save)
# Navigation — synchronous, safe to read state immediately after
Mob.Test.back(node) # system back gesture (fire-and-forget)
Mob.Test.pop(node) # pop to previous screen (synchronous)
Mob.Test.navigate(node, MyApp.DetailScreen, %{id: 42})
Mob.Test.pop_to(node, MyApp.HomeScreen)
Mob.Test.pop_to_root(node)
Mob.Test.reset_to(node, MyApp.HomeScreen)
# List interaction
Mob.Test.select(node, :my_list, 0) # select first row
# Simulate device API results (permission dialogs, camera, location, etc.)
Mob.Test.send_message(node, {:permission, :camera, :granted})
Mob.Test.send_message(node, {:camera, :photo, %{path: "/tmp/p.jpg", width: 1920, height: 1080}})
Mob.Test.send_message(node, {:location, %{lat: 43.65, lon: -79.38, accuracy: 10.0, altitude: 80.0}})
Mob.Test.send_message(node, {:notification, %{id: "n1", title: "Hi", body: "Hey", data: %{}, source: :push}})
Mob.Test.send_message(node, {:biometric, :success})
```
### Accessing IEx alongside an agent
**Option 1 — shared session (`iex -S mix mob.server`):**
```bash
iex -S mix mob.server
```
Starts the dev dashboard and gives you an IEx prompt in the same process. The agent uses Tidewave to execute `Mob.Test.*` calls in this session; you type directly in the same IEx prompt. Both share the same connected node and see the same live state. This is the recommended setup for working alongside an agent.
**Option 2 — separate sessions (`--name`):**
Because Erlang distribution allows multiple nodes to connect to the same device, you can run independent sessions simultaneously:
```bash
# Your terminal
mix mob.connect --name mob_dev_1@127.0.0.1
# Agent's terminal (or a second developer)
mix mob.connect --name mob_dev_2@127.0.0.1
```
Both connect to the same device nodes, can call `Mob.Test.*` and `nl/1`, and don't interfere with each other.
### MCP tool setup
For native UI interaction (screenshots, native gestures, accessibility inspection), install MCP servers for Claude Code:
**Android — `adb-mcp`:**
```bash
npm install -g adb-mcp
```
Add to `~/.claude.json`:
```json
{
"mcpServers": {
"adb": {
"command": "npx",
"args": ["adb-mcp"]
}
}
}
```
**iOS simulator — `ios-simulator-mcp`:**
```bash
npm install -g ios-simulator-mcp
```
Add to `~/.claude.json`:
```json
{
"mcpServers": {
"ios-simulator": {
"command": "ios-simulator-mcp"
}
}
}
```
With these installed, Claude Code can take screenshots, inspect the accessibility tree, and simulate gestures on the native device — useful when you need to verify layout or test native gesture paths.
### Recommended CLAUDE.md for Mob projects
Add a `CLAUDE.md` to your Mob project root to give an agent the context it needs:
````markdown
# MyApp — Agent Instructions
## Connecting to a running device
```bash
mix mob.connect # discover, tunnel, connect IEx
mix mob.connect --no-iex # print node names without IEx
mix mob.devices # list connected devices
```
Node names:
- iOS simulator: `my_app_ios@127.0.0.1`
- Android emulator: `my_app_android@127.0.0.1`
## Inspecting and driving the running app
Prefer `Mob.Test` over screenshots — it gives exact state, not a visual approximation.
```elixir
node = :"my_app_ios@127.0.0.1"
# Inspection
Mob.Test.screen(node) # current screen module
Mob.Test.assigns(node) # current assigns map
Mob.Test.find(node, "text") # find UI nodes by visible text
Mob.Test.inspect(node) # full snapshot: screen + assigns + nav history + tree
# Interaction
Mob.Test.tap(node, :tag) # tap by tag atom (from on_tap: {self(), :tag} in render/1)
Mob.Test.back(node) # system back gesture
Mob.Test.pop(node) # pop to previous screen (synchronous)
Mob.Test.navigate(node, Screen, %{}) # push a screen (synchronous)
Mob.Test.select(node, :list_id, 0) # select a list row
# Simulate device API results
Mob.Test.send_message(node, {:permission, :camera, :granted})
Mob.Test.send_message(node, {:camera, :photo, %{path: "/tmp/p.jpg", width: 1920, height: 1080}})
Mob.Test.send_message(node, {:biometric, :success})
```
Navigation functions (`pop`, `navigate`, `pop_to`, `pop_to_root`, `reset_to`) are
synchronous — safe to read state immediately after.
`back/1` and `send_message/2` are fire-and-forget. If you need to wait:
```elixir
Mob.Test.back(node)
:rpc.call(node, :sys, :get_state, [:mob_screen]) # flush
Mob.Test.screen(node)
```
## Hot-pushing code changes
```bash
mix mob.push # compile + push all changed modules to all connected devices
mix mob.push --all # force-push every module
```
## Deploying
```bash
mix mob.deploy # push changed BEAMs, restart
mix mob.deploy --native # full native rebuild + install
```
````
### Agent workflow example
A typical agent session for debugging or feature work:
```
1. mix mob.connect — connect to the running device node
2. Mob.Test.screen(node) — confirm which screen is showing
3. Mob.Test.assigns(node) — inspect current state
4. Mob.Test.tap(node, :some_button) — interact with the UI
5. Mob.Test.screen(node) — confirm navigation happened
6. edit lib/my_app/screen.ex — make a code change
7. mix mob.push — hot-push changed modules without restart
8. Mob.Test.assigns(node) — verify state updated as expected
```
For device API interactions, simulate the result rather than triggering real hardware:
```elixir
# Instead of actually opening the camera:
Mob.Test.tap(node, :take_photo) # triggers handle_event → Mob.Camera.capture_photo
# Simulate the result:
Mob.Test.send_message(node, {:camera, :photo, %{path: "/tmp/test.jpg", width: 1920, height: 1080}})
Mob.Test.assigns(node) # verify photo_path was stored
```
If you need to see the rendered UI, take a screenshot with the native MCP tool, then use `Mob.Test.find/2` to correlate what you see with the component tree.