Skip to main content

Self-Hosted Mobile Devices

Tier 2 — runs mobile tests on Android emulator pods that you operate in your own K8s cluster. Best balance of cost + control for sustained CI usage, and the strongest "data stays in our infra" posture.

Status — fully shipped

The full T2 stack ships as of DF.2.c:

  • DF.2.a — backend mobile_device registry
  • DF.2.b — agent SelfHostedProvider (dispatch + release)
  • DF.2.candroid-emulator-pod Docker image + Helm chart

See helm/android-emulator-pod for the chart and docker/android-emulator-pod for the image source.

Quick install

# 1. Store the agent token (the same one mobile-test-agent uses)
kubectl create secret generic emulator-agent-token \
--from-literal=agent-token=$AGENT_TOKEN -n proofarc

# 2. Install the chart
helm install emulators ./helm/android-emulator-pod \
--namespace proofarc \
--set backend.url=http://proofarc-backend.proofarc.svc.cluster.local:8080 \
--set backend.existingSecret=emulator-agent-token \
--set replicaCount=4

# 3. Confirm registration
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/mobile-devices

After ~60-90s of cold start per pod, four AVAILABLE devices appear in the registry. The agent's SelfHostedProvider will pick them up at job time.

Full chart docs: helm/android-emulator-pod/README.md.

Architecture

┌──────────────────────────── Customer K8s cluster ────────────────────────────┐
│ │
│ ┌────────────────┐ poll jobs ┌──────────────────┐ WebDriver │
│ │ Backend API │◄──────────────►│ mobile-test- │ ────────────┐ │
│ │ (Spring Boot) │ │ agent (Java) │ │ │
│ │ │ register └──────────────────┘ │ │
│ │ mobile_device │◄─────┐ │ │
│ │ registry table │ │ ▼ │
│ └────────────────┘ │ ┌────────────────────┐ │
│ │ POST /register on │ android-emulator- │ │
│ │ startup, heartbeat │ pod (KVM node) │ │
│ │ every 30s │ ┌────────────────┐ │ │
│ └──────────────────────────┤ │ Appium :4723 │ │ │
│ │ ├────────────────┤ │ │
│ │ │ AVD │ │ │
│ │ │ Pixel_7_API_34 │ │ │
│ │ └────────────────┘ │ │
│ └────────────────────┘ │
│ (1 pod per device, │
│ scale horizontally) │
└──────────────────────────────────────────────────────────────────────────────┘

Cluster prerequisites

Self-hosted Android emulators require nested virtualization (KVM) on the K8s nodes. Without it, the emulator can boot but runs in software-emulation mode — orders of magnitude slower.

CloudKVM-capable instances
AWSBare-metal instances (c5.metal, i3.metal, c5n.metal). Most non-metal families don't support nested virt for KVM passthrough.
GCPMost n2/n2d families when created with --enable-nested-virtualization.
AzureDv3 / Ev3 / Dv5 / Ev5 families support nested virt out of the box.
On-prem bare metalJust works.

You also need /dev/kvm exposed to the emulator pod — either privileged mode or the KVM device plugin.

How the registry works

Every emulator pod is a row in the mobile_device table. The lifecycle:

pod startup

▼ POST /api/agent/mobile-devices/register
[ AVAILABLE ]──────┐
▲ │ POST /pick-available (atomic AVAILABLE → BUSY)
│ ▼
│ [ BUSY ]
│ │ POST /{id}/release (after test)
└───────────────┘

│ no heartbeat for N minutes → admin marks offline

[ OFFLINE ]

▼ heartbeat resumes → automatic recovery
[ AVAILABLE ]

Concurrency is safe: if two agent jobs race for the same device, optimistic locking in the registry ensures only one wins; the other automatically retries with the next candidate.

API reference

Pod self-registration

POST /api/agent/mobile-devices/register

{
"name": "android-emulator-prod-01",
"agentId": "mobile-test-agent",
"platform": "ANDROID",
"platformVersion": "13.0",
"deviceName": "Pixel_7_API_34",
"appiumUrl": "http://android-emulator-prod-01.proofarc.svc.cluster.local:4723",
"capabilities": {
"appium:autoGrantPermissions": true
}
}

Idempotent — re-registering with the same (agentId, name) updates the existing row and resets status to AVAILABLE. Useful after pod restarts.

Pick an available device (agent)

POST /api/agent/mobile-devices/pick-available?platform=ANDROID&platformVersion=13.0&jobId=test-job-1

Returns the picked device (now BUSY):

{
"id": 7,
"name": "android-emulator-prod-01",
"appiumUrl": "http://android-emulator-prod-01.proofarc.svc.cluster.local:4723",
"deviceName": "Pixel_7_API_34",
"automationName": "UiAutomator2",
"status": "BUSY",
"lastJobId": "test-job-1"
}

Returns 404 when no AVAILABLE device matches the requested platform/version.

Release after test

POST /api/agent/mobile-devices/{id}/release — flips BUSYAVAILABLE. Idempotent.

Heartbeat

POST /api/agent/mobile-devices/{id}/heartbeat — pods should call this every ~30s. Recovers OFFLINEAVAILABLE automatically when a previously-dead pod comes back.

Admin operations

GET /api/mobile-devices list all (filter: ?status=...)
GET /api/mobile-devices/{id} device detail
POST /api/mobile-devices/{id}/mark-offline force OFFLINE (ADMIN)
DELETE /api/mobile-devices/{id} unregister stale row (ADMIN)

Configuring a mobile app for T2

{
"cloudProvider": "SELF_HOSTED",
"cloudConfig": {
"platformVersion": "13.0"
}
}

That's it. The agent will:

  1. Call /pick-available?platform=ANDROID&platformVersion=13.0
  2. Get back a device with its appiumUrl
  3. Connect the WebDriver session to that URL
  4. Run the test
  5. Call /{id}/release when done

If no device is available, the agent surfaces the error in Job History — operator can either spin up more pods or fall back to a vendor farm.

Capability matching

v1 supports matching on:

  • platform (ANDROID / IOS — IOS not yet implemented for self-hosted)
  • platformVersion (optional — exact match if specified, any version if omitted)

Future versions can extend the match with more capabilities (device_name, model, screen size). Today: if you need different device models, register them as separate mobile_device rows and add filtering logic in your test or wrapper code.

Pod image (coming soon — DF.2.c)

The android-emulator-pod image will bundle:

  • Android SDK + a pinned AVD (default: Pixel_7_API_34)
  • Appium server with the uiautomator2 driver
  • A small registration sidecar (Go or shell) that POSTs to /register on startup and sends /heartbeat every 30s
  • A graceful-shutdown handler that posts to /release on SIGTERM

Until that image lands, you can roll your own using budtmo/docker-android plus a startup script that calls the registration endpoint with the right metadata.

Cost sanity check

SetupApprox monthly cost (1 instance, 100h/month)
1× AWS c5.metal (96 vCPU, 192 GB RAM)~$500 — hosts ~4-8 emulator pods comfortably
1× GCP n2-standard-32 with nested virt~$700 — hosts ~3-5 pods
Equivalent BrowserStack (1 parallel)~$200/mo, no setup, no ops

T2 wins when:

  • You run > 3 hours/day of test execution
  • You can't send APKs / test data outside your infra (regulatory)
  • You want predictable cost regardless of test volume

T4 (BrowserStack/Sauce) wins when:

  • You need iOS without buying Macs
  • You don't want to operate K8s with KVM nodes
  • Your test volume is bursty

Many teams use both — T2 for steady CI, T4 for release-day device matrix.

Real devices (T3, future)

Real-device farms (OpenSTF / DeviceFarmer) use the same mobile_device registry shape. Each physical device registers with its USB serial number in capabilities. Lower priority since most teams don't want to operate device hardware — but the data model accommodates it.

What's NOT in the registry yet

  • iOS support — Android-only for now. iOS Simulator requires macOS hardware (EC2 Mac for self-hosted iOS, or use T4 BrowserStack/Sauce).
  • Automatic OFFLINE sweeper — admin manually marks dead devices via mark-offline endpoint. Cron-based stale-device sweep can come later.
  • Pod image — DF.2.c will ship the canonical android-emulator-pod Docker image + Helm chart.

Next steps