mob_dev

Development tooling for Mob — the BEAM-on-device mobile framework for Elixir.

Hex.pm

Installation

Add to your project's mix.exs (dev only):

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:

Run with IEx for an interactive terminal alongside the dashboard:

iex -S mix mob.server

Watch mode

Click Watch in the dashboard header or control it programmatically:

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:

{: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.

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.

# 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.

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.

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):

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:

# 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:

npm install -g adb-mcp

Add to ~/.claude.json:

{
  "mcpServers": {
    "adb": {
      "command": "npx",
      "args": ["adb-mcp"]
    }
  }
}

iOS simulator — ios-simulator-mcp:

npm install -g ios-simulator-mcp

Add to ~/.claude.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:

# 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:

# 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.