avdctl is a CLI tool for managing Android emulators and iOS simulators.
- Android workflows support base AVDs, golden images, QCOW2-backed clones, and parallel headless execution.
- iOS workflows support base simulators, cloning configured shut-down simulators, and boot/stop/list lifecycle operations through
xcrun simctl.
Linux and macOS:
# choose your platform archive (example: linux amd64)
curl -L -o avdctl.tar.gz \
https://github.com/ForkbombEu/avdctl/releases/latest/download/avdctl_linux_amd64.tar.gz
sudo tar -xzf avdctl.tar.gz -C /usr/local/bin avdctlMake sure the install directory is on your PATH:
export PATH="/usr/local/bin:$PATH"GOBIN="$HOME/.local/bin" go install github.com/forkbombeu/avdctl/cmd/avdctl@latestPublished image: ghcr.io/forkbombeu/avdctl (tags: latest, vX.Y.Z)
FROM ghcr.io/forkbombeu/avdctl:latest AS avdctl-bin
FROM debian:bookworm-slim
COPY --from=avdctl-bin /usr/local/bin/avdctl /usr/local/bin/avdctl-
Android SDK with command-line tools installed:
emulatoradbavdmanagersdkmanager
-
QEMU utilities:
# Debian/Ubuntu sudo apt install qemu-utils # macOS brew install qemu
-
Go 1.25+ (for building from source)
-
Xcode command line tools /
xcrun simctl(required for iOS commands on macOS) -
Task (optional, for using Taskfile workflows):
# Install from https://taskfile.dev go install github.com/go-task/task/v3/cmd/task@latest
Set these environment variables (or let them use defaults):
export ANDROID_SDK_ROOT=/opt/android-sdk # Your Android SDK path
export ANDROID_AVD_HOME=$HOME/.android/avd # Default: ~/.android/avd
export AVDCTL_GOLDEN_DIR=$HOME/avd-golden # Default: ~/avd-golden
export AVDCTL_CONFIG_TEMPLATE=/path/to/config.ini.tpl # Optional: custom config template
export AVDCTL_SSH_TARGET=android@remote-builder # Optional: run tool commands over SSH
export AVDCTL_SSH_ARGS="-p 2222 -o BatchMode=yes" # Optional: extra ssh argsAll CLI subcommands also support:
--ssh user@host--ssh-arg <value>(repeatable)
When --ssh is enabled, avdctl delegates the whole command to a remote avdctl
process over a single SSH session. Path arguments (for example --golden, --dest)
are interpreted on the remote host, not on the local machine. Ensure avdctl is
installed on the remote host and available in PATH.
The following commands support both platforms:
listinit-baserunclonedeletepsstatusstop
You can call them in either of these forms:
# Omitted platform behavior
# - list / ps return both Android and iOS
# - status --all returns both Android and iOS
# - run / status / stop / delete auto-detect by name or ref
# - Android wins if Android and iOS share the same name
./bin/avdctl run --name base-a35
./bin/avdctl list
./bin/avdctl clone --base base-a35 --name w-demo --golden ~/avd-golden/base-a35
# Explicit platform selection
./bin/avdctl run android --name base-a35
./bin/avdctl run ios --name base-ios
./bin/avdctl clone ios --base base-ios --name ios-demoAndroid-only commands remain:
save-goldenprewarmcustomize-startcustomize-finishbake-apkstop-bluetoothcleanup
# Using Task
task build
# Or using Go directly
go build -o bin/avdctl ./cmd/avdctl
# Verify
./bin/avdctl --help# Create a base AVD (Android 35, Google Play Store, Pixel 6 profile)
# This will auto-download the system image if not present
./bin/avdctl init-base --name base-a35 \
--image "system-images;android-35;google_apis_playstore;x86_64" \
--device pixel_6The iOS flow uses a configured shut-down simulator as the base instead of an exported golden image:
# Create a base simulator (latest available iOS runtime + iPhone type by default)
./bin/avdctl init-base ios --name base-ios
# Boot it and perform any manual setup you want to preserve
./bin/avdctl run ios --name base-ios
# After manual configuration, shut it down cleanly
./bin/avdctl stop ios --name base-ios
# Clone the configured base
./bin/avdctl clone ios --base base-ios --name ios-customer1
# Boot the clone
./bin/avdctl run ios --name ios-customer1Note: iOS cloning works from a shut-down base simulator. There is no save-golden or prewarm equivalent for iOS at the moment.
avdctl also supports Redroid lifecycle operations:
# Restore data tar + start container
./bin/avdctl redroid start \
--name redroid15 \
--image magsafe/redroid15gappsmagisk:latest \
--data-dir "$HOME/redroid-data" \
--data-tar "$HOME/redroid-data.tar" \
--port 5555
# Wait until Android framework services are ready
./bin/avdctl redroid wait --serial 127.0.0.1:5555 --timeout 3m
# Stop/remove container
./bin/avdctl redroid stop --name redroid15
./bin/avdctl redroid delete --name redroid15Now boot the AVD with a GUI to configure it manually:
# Start emulator with window (NOT using avdctl)
emulator -avd base-a35 -no-snapshotIn the emulator, perform your manual configuration:
- ✅ Add Google account (Play Store login)
- ✅ Enroll fingerprint (Settings → Security → Fingerprint)
- ✅ Install any base apps you want in all clones
- ✅ Adjust system settings (locale, timezone, etc.)
- ✅ Disable animations (Settings → Developer Options → Window/Transition/Animator scale → off)
- ✅ Enable "Stay awake" (Developer Options)
- ✅ Configure Wi-Fi/network settings
- ✅ Accept all first-run wizards
Important: Let the emulator fully settle (30-60 seconds idle) after all changes.
Then shutdown cleanly:
# From another terminal
adb emu kill
# OR from emulator console
adb shell reboot -p# Export the configured userdata as a compressed golden QCOW2
./bin/avdctl save-golden --name base-a35 \
--dest "$HOME/avd-golden/base-a35-configured.qcow2"Alternatively, use prewarm for automated boot+save:
# Boot once, wait for Android to fully start, settle caches, then save
# (No manual intervention - good for clean base images without Google account)
./bin/avdctl prewarm --name base-a35 \
--dest "$HOME/avd-golden/base-a35-prewarmed.qcow2" \
--extra 30s \
--timeout 3mUse prewarm for clean bases, save-golden after manual configuration.
Each customer gets a lightweight clone backed by the golden image:
# Customer 1
./bin/avdctl clone --base base-a35 --name w-customer1 \
--golden "$HOME/avd-golden/base-a35-configured.qcow2"
# Customer 2
./bin/avdctl clone --base base-a35 --name w-customer2 \
--golden "$HOME/avd-golden/base-a35-configured.qcow2"
# Customer 3
./bin/avdctl clone --base base-a35 --name w-customer3 \
--golden "$HOME/avd-golden/base-a35-configured.qcow2"Naming convention: w-<slug> (e.g., w-acme, w-contoso, w-initech)
# Auto-assign ports (finds free even port pair)
./bin/avdctl run --name w-customer1
# Or specify explicit ports for parallel instances
./bin/avdctl run --name w-customer1 --port 5580
./bin/avdctl run --name w-customer2 --port 5582
./bin/avdctl run --name w-customer3 --port 5584Port notes:
- Must be even numbers (emulator uses port + port+1)
- Each instance needs a unique port pair
- Default range: 5554-5586 (adb auto-discovery range)
# Human-readable output
./bin/avdctl ps
# JSON output
./bin/avdctl ps --json
# Check specific instance status
./bin/avdctl status --name w-customer1
./bin/avdctl status --serial emulator-5580# By name
./bin/avdctl stop --name w-customer1
# By serial
./bin/avdctl stop --serial emulator-5580./bin/avdctl list
./bin/avdctl list --json./bin/avdctl delete w-customer1Note: This only deletes the clone's overlay (a few MB). The golden image remains untouched.
Pre-install APKs into a golden image for faster clone startup:
./bin/avdctl bake-apk --base base-a35 --name w-baked \
--golden "$HOME/avd-golden/base-a35-configured.qcow2" \
--apk /path/to/app1.apk \
--apk /path/to/app2.apk \
--dest "$HOME/avd-golden/base-a35-with-apps.qcow2"This creates a new golden image with APKs pre-installed. Use it for clones:
./bin/avdctl clone --base base-a35 --name w-customer-with-apps \
--golden "$HOME/avd-golden/base-a35-with-apps.qcow2"If you have a custom config.ini.tpl, set it before cloning:
export AVDCTL_CONFIG_TEMPLATE=/path/to/custom-config.ini.tpl
./bin/avdctl clone --base base-a35 --name w-custom ...# Start 4 instances in parallel
for i in {1..4}; do
port=$((5580 + (i-1)*2))
./bin/avdctl run --name w-test$i --port $port &
done
# Wait for all to boot
sleep 30
./bin/avdctl ps
# Run tests against each
adb -s emulator-5580 shell am instrument ...
adb -s emulator-5582 shell am instrument ...
adb -s emulator-5584 shell am instrument ...
adb -s emulator-5586 shell am instrument ...
# Stop all
for i in {1..4}; do
port=$((5580 + (i-1)*2))
./bin/avdctl stop --serial emulator-$port
done# 1. Build tool
task build
# 2. Create directories
mkdir -p ~/avd-golden
# 3. Create base AVD
./bin/avdctl init-base --name base-a35
# 4. Boot manually and configure (Google account, fingerprint, etc.)
emulator -avd base-a35 -no-snapshot
# ... do manual setup in GUI ...
# ... close emulator when done ...
# 5. Save the golden image
./bin/avdctl save-golden --name base-a35 \
--dest ~/avd-golden/base-a35-configured.qcow2
# 6. Create customer clones
./bin/avdctl clone --base base-a35 --name w-customer1 \
--golden ~/avd-golden/base-a35-configured.qcow2
./bin/avdctl clone --base base-a35 --name w-customer2 \
--golden ~/avd-golden/base-a35-configured.qcow2
# 7. Run both in parallel
./bin/avdctl run --name w-customer1 --port 5580 &
./bin/avdctl run --name w-customer2 --port 5582 &
# 8. Verify
sleep 10
./bin/avdctl ps
adb devices
# 9. Stop when done
./bin/avdctl stop --name w-customer1
./bin/avdctl stop --name w-customer2If you prefer Task automation:
# Full workflow
task fresh # Clean → init-base → prewarm → clone 2 customers
# Individual tasks
task build # Build binary
task init-base # Create base-a35
task prewarm # Prewarm base-a35
task clone-customer # Clone a customer (edit Taskfile.yml for name)
task run-customer # Run a customer (edit Taskfile.yml for name/port)
task ps # List running
task stop NAME=w-customer1 # Stop instance
task clean-avds # Delete all AVDs (danger!)Edit Taskfile.yml to customize names, ports, and golden paths.
Check logs at /tmp/emulator-<name>-<port>.log:
tail -f /tmp/emulator-w-customer1-5580.logThis shouldn't happen with the latest version (uses QEMU_FILE_LOCKING=off). If you see it:
- Ensure you're using the latest build
- Check for stale emulator processes:
ps aux | grep emulator - Kill them:
killall qemu-system-x86_64-headless
# Find free port
./bin/avdctl run --name w-customer1 # Auto-assigns free port
# Or manually check
lsof -i :5580Clones are QCOW2 overlays - they only store changes. But if a clone's userdata grows too large:
# Check size
ls -lh ~/.android/avd/w-customer1.avd/userdata-qemu.img.qcow2
# If too large, delete and recreate
./bin/avdctl delete w-customer1
./bin/avdctl clone --base base-a35 --name w-customer1 --golden ~/avd-golden/base-a35-configured.qcow2- Disable animations in Developer Options (in the golden image)
- Use
--extraflag withprewarmto let caches settle - Use SSD storage for AVD home and golden directory
- Allocate more RAM in
config.ini.tpl(default: 4GB)
- Base AVD: Clean Android system created via
avdmanager - Golden Image: Compressed QCOW2 snapshot of configured userdata
- Clone: Symlinks to base AVD read-only files + thin QCOW2 overlay backed by golden
- Parallel Safe: Uses
QEMU_FILE_LOCKING=offand-read-onlyfor shared backing files
Disk Usage:
- Base AVD: ~8GB (system image + initial userdata)
- Golden QCOW2: ~500MB-2GB (compressed, depends on configuration)
- Clone overlay: ~196KB initially, grows with changes (typically <100MB)
- Base Simulator: Configured CoreSimulator device kept as the source of truth
- Clone: Created from the shut-down base via
xcrun simctl clone - Reset Strategy: Delete and clone again from the base when a simulator becomes dirty
- Tradeoff: Simulators are tied more closely to the host macOS/Xcode/CoreSimulator environment than Android golden images
AGPL-3.0-only
Copyright (C) 2025 Forkbomb B.V.
For a fully containerized environment with Android SDK pre-installed:
- QUICKSTART-DOCKER.md - Step-by-step guide to get running quickly
- DOCKER.md - Complete Docker reference and advanced usage
# Quick start with Docker
docker-compose up -d --build
docker-compose exec avdctl bash
avdctl init-base --name base-a35You can import avdctl as a library in your Go projects:
import "github.com/forkbombeu/avdctl/pkg/avdmanager"
import "github.com/forkbombeu/avdctl/pkg/iosmanager"
mgr := avdmanager.New()
mgr.InitBase(avdmanager.InitBaseOptions{...})
mgr.Clone(avdmanager.CloneOptions{...})
mgr.Run(avdmanager.RunOptions{...})
iosMgr := iosmanager.New()
iosMgr.InitBase(iosmanager.InitBaseOptions{...})
iosMgr.Clone(iosmanager.CloneOptions{...})
iosMgr.Run(iosmanager.RunOptions{...})See pkg/avdmanager/README.md for complete API documentation and examples.
- pkg/avdmanager - Go library API documentation
- PORT-MANAGEMENT.md - Parallel execution and port management guide
- QUICKSTART-DOCKER.md - Docker quick start guide
- DOCKER.md - Docker setup with full Android SDK and emulator
- CRUSH.md - Detailed development guide for contributors
- Taskfile.yml - Task automation examples
- config.ini.tpl - AVD configuration template