Declare an Android device in Kubernetes, wait until it is ready, run commands on it, capture its screen, and open an interactive viewer.
Status: experimental alpha. MDO is a working prototype for exploring a provider-neutral Kubernetes device API. It is not a production device cloud and currently targets Android only.
Mobile Device Operator (MDO) is a deliberately small, backend-agnostic Kubernetes operator prototype. It explores what mobile-device infrastructure could look like when a phone or emulator is represented by a declarative API instead of an external testing dashboard.
This animation is rendered from checks already validated against a real hosted Android Device; VHS only presents the verified transcript and never owns Kubernetes, ADB, or MDO processes.
The CI artifact also contains the terminal MP4, Android viewer evidence (view-android.mp4), the real Android screenshot, the E2E summary, the VHS tape, and checksums. See docs/demo for the reproducible generation and publication flow.
Assuming the operator, CRDs, DeviceClass, and ADB key are installed:
kubectl apply -f config/samples/devices_v1alpha1_device.yaml
kubectl wait --for=condition=Ready device/mobile-demo --timeout=20m
bin/mdo exec mobile-demo -- getprop sys.boot_completed
bin/mdo screenshot mobile-demo --output phone.pngThe same Device can be attached to local ADB or opened with a local interactive viewer:
bin/mdo connect mobile-demo
bin/mdo view mobile-demoThese are not mocked API responses. The hosted E2E starts Kubernetes and an Android emulator, exercises the real CLI paths, captures a valid PNG, validates the mdo view viewer-launch contract through the MDO-managed ADB tunnel, and stores the complete evidence as workflow artifacts.
Device manifest
│
▼
DeviceClass selects an Android provider image
│
▼
Controller creates the provider workload
│
▼
Provider workload starts
Device.status.phase = Starting
Ready=False / ProviderStarting
│
▼
Provider-specific readiness succeeds
│
▼
Device.status.phase = Ready and publishes the endpoint
│
├── mdo connect persistent local ADB session
├── mdo exec one-shot Android shell command
├── mdo screenshot one-shot PNG capture
└── mdo view local interactive viewer (scrcpy first)
Starting is a provider-neutral lifecycle phase: it means that the backing runtime is running, but the selected provider has not yet declared the device usable. Ready is therefore a consumer contract, not merely a container-health signal. Each provider owns the platform-specific checks behind that boundary; the core Device lifecycle only consumes the provider's Kubernetes readiness result.
For the current Android emulator provider, the provider workload does not become ready until Android has completed boot, the current user is unlocked, a real HOME activity has replaced the startup fallback, and that HOME activity is actually resumed. Those Android details do not appear in the public Device API.
The important experiment is the control-plane contract: callers use the same Kubernetes Device API while provider-specific details remain behind DeviceClass and the provider implementation.
apiVersion: devices.mdo.io/v1alpha1
kind: DeviceClass
metadata:
name: android-emulator-default
spec:
platform: android
provider: android-emulator
image: us-docker.pkg.dev/android-emulator-268719/images/30-google-x64:30.1.2
---
apiVersion: devices.mdo.io/v1alpha1
kind: Device
metadata:
name: mobile-demo
spec:
platform: android
className: android-emulator-defaultMDO exposes its class resource as mobiledeviceclasses.devices.mdo.io, with the unambiguous short name mdo-class, because Kubernetes also has a native DeviceClass kind.
The current Android provider requires a Linux x86-64 environment with usable /dev/kvm. The development setup is intentionally heavyweight: plan for at least 6 CPU cores, 12 GiB RAM, and roughly 40 GiB free on the container-runtime filesystem. See docs/MINIKUBE_ANDROID_E2E.md before using Minikube's none driver.
Start Minikube and label the KVM-capable node:
sudo -E env CHANGE_MINIKUBE_NONE_USER=true minikube start \
--driver=none \
--container-runtime=containerd
NODE_NAME="$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')"
kubectl label node "$NODE_NAME" devices.mdo.io/kvm=true --overwriteInstall the APIs and default Android DeviceClass:
kubectl apply -f config/crd/bases/devices.mdo.io_mobiledeviceclasses.yaml
kubectl apply -f config/crd/bases/devices.mdo.io_devices.yaml
kubectl apply -f config/samples/devices_v1alpha1_deviceclass.yamlBuild the operator image inside Minikube and deploy the controller:
minikube image build -t mobile-device-operator:e2e .
kubectl apply -f config/deploy/minikube.yaml
kubectl rollout status \
deployment/mobile-device-operator-controller-manager \
--namespace mobile-device-operator-system \
--timeout=180sAuthorize the local ADB identity. Only the public key is copied into Kubernetes; keep $HOME/.android/adbkey private.
adb start-server
kubectl create secret generic mdo-adb-public-key \
--from-file=adbkey.pub="$HOME/.android/adbkey.pub" \
--dry-run=client -o yaml | kubectl apply -f -Create a Device, wait on the portable readiness contract, and build the CLI:
kubectl apply -f config/samples/devices_v1alpha1_device.yaml
kubectl wait --for=condition=Ready device/mobile-demo --timeout=20m
make build-mdoUse persistent access, one-shot commands, screenshot capture, or the local viewer:
bin/mdo connect mobile-demo
bin/mdo exec mobile-demo -- getprop ro.product.model
bin/mdo screenshot mobile-demo --output phone.png
bin/mdo view mobile-demoThe CLI depends on locally installed kubectl and adb and uses the current kubeconfig context. mdo view additionally requires scrcpy; see docs/VIEW.md for the design decision, viewer options, hosted contract validation, and manual interactive test. Existing screenshot files are not overwritten; raw PNG bytes can be written with --output -.
For eligible same-repository pull requests, one privileged Android E2E job validates the DeviceClass and Device lifecycle, mdo connect, mdo exec, a real PNG produced by mdo screenshot, and the mdo view launch contract. The E2E waits on the public Device Ready condition before using the device; provider-specific boot synchronization belongs to the provider readiness implementation, not to CI callers.
The hosted viewer probe is deliberately named scrcpy so the public command follows its normal lookup path, then verifies that MDO supplies the expected TCP ADB serial, forward mode, IPv4 tunnel host, process lifecycle, and cleanup. It also captures a real Android frame through that serial and produces deterministic-duration video evidence.
The hosted check does not claim to certify the real scrcpy client/server or desktop interaction. Actual scrcpy rendering, mouse, keyboard, clipboard, rotation, and graphical-window behavior remain a workstation validation. This keeps CI deterministic while still proving the MDO-specific integration boundary.
The recorded demo reuses the evidence artifact from that same Android E2E job. It does not start a second emulator: a lightweight renderer job turns the verified results into the terminal animation and final media.
Contributions are welcome; see CONTRIBUTING.md. Please report suspected vulnerabilities privately according to SECURITY.md.
MDO is an experimentation platform, not a production device cloud. The prototype intentionally excludes public gateways, accounts, billing, multi-tenancy, browser streaming, recording services, production availability guarantees, iOS support, and multiple production-ready providers.
The long-term experiment is a provider-neutral API that could represent different Android runtimes or physical-device pools without changing the consumer workflow. See docs/PROJECT.md for the vision, architecture, scope, and roadmap.