Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# Compile the EGL hook without leaving gcc in the runtime image.
FROM ubuntu:20.04 AS egl-hook
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
gcc \
libc6-dev && \
rm -rf /var/lib/apt/lists/*
COPY egl-gbm-hook.c /tmp/egl-gbm-hook.c
RUN gcc -shared -fPIC -O2 -o /libegl-gbm-hook.so /tmp/egl-gbm-hook.c -ldl

FROM ubuntu:20.04

# Install necessary packages
Expand Down Expand Up @@ -83,6 +93,32 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY first-boot.sh /root/first-boot.sh
RUN chmod +x /root/first-boot.sh

# Host GPU userspace (Intel/AMD/NVIDIA via Mesa). Used when GPU_MODE=host.
# Own layer so the SDK download above stays cached. modesetting is in
# xserver-xorg-core; the intel DDX package is not required.
COPY --from=egl-hook /libegl-gbm-hook.so /usr/local/lib/libegl-gbm-hook.so
COPY xorg-headless.conf /etc/X11/xorg-headless.conf
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
-o Dpkg::Options::=--force-confdef \
-o Dpkg::Options::=--force-confold \
libgl1-mesa-dri \
libgl1 \
libglx-mesa0 \
libgles2 \
libegl1-mesa \
libgbm1 \
mesa-vulkan-drivers \
libvulkan1 \
xvfb \
xserver-xorg-core \
x11-xserver-utils \
libxtst6 \
libglu1-mesa \
libxv1 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

# Copy the start-emulator script
COPY start-emulator.sh /root/start-emulator.sh
RUN chmod +x /root/start-emulator.sh
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Access and control the Android emulator directly in your web browser with the in
- **Easy Setup:** Simple Docker commands to build and run the emulator.
- **Supervisor Management:** Manages emulator processes with Supervisor for reliability.
- **Unified Container Logs:** All emulator and boot logs are redirected to Docker's standard log system.
- **Optional Host GPU:** Set `GPU_MODE=host` and pass `/dev/dri` to render guest GLES on the host GPU (Intel/AMD/NVIDIA via Mesa) instead of SwiftShader.

## 🛠️ **Prerequisites**

Expand Down Expand Up @@ -129,6 +130,32 @@ List of devices attached
localhost:5555 device
```

### Host GPU (`GPU_MODE=host`)

The default renderer is SwiftShader (CPU). To use an Intel/AMD/NVIDIA GPU:

1. Pass the DRM device into the container (`/dev/dri`).
2. Set `GPU_MODE=host`.
3. If the container is not privileged, add the host GID that owns `/dev/dri` via `group_add`.

```yaml
environment:
GPU_MODE: host
devices:
- /dev/kvm
- /dev/dri:/dev/dri
# group_add:
# - "44" # `stat -c %g /dev/dri/renderD128`
```

Headless hosts do not need a monitor. The emulator still runs `-no-window`; viewing stays on scrcpy / scrcpy-web. A small Xorg on `/dev/dri/card0` provides GLX (Mesa iris/amdgpu/nouveau). Confirm the guest actually picked the GPU:

```bash
adb shell dumpsys SurfaceFlinger | grep GLES
```

You should see the host GPU name (for example `Mesa Intel(R) UHD Graphics 630`), not `Google SwiftShader`.

### Use scrcpy to Mirror the Emulator Screen

For a native desktop experience, you can use scrcpy:
Expand All @@ -150,6 +177,7 @@ scrcpy -s localhost:5555
| `ROOT_SETUP` | Set to `1` to enable rooting and Magisk. Can be turned on after the first start but cannot be undone without recreating the data volume. | `0` |
| `GAPPS_SETUP` | Set to `1` to install PICO GAPPS. Can be turned on after the first start but cannot be undone without recreating the data volume. | `0` |
| `ARM_TRANSLATION` | Set to `1` to enable ARM translation (ndk_translation) for running ARM/ARM64 apps on x86_64. Can be turned on after the first start but cannot be undone without recreating the data volume. | `0` |
| `GPU_MODE` | Emulator GPU backend. `swiftshader_indirect` (default) is software rendering. `host` uses the host GPU through Mesa. | `swiftshader_indirect` |


## 🔄 **First Boot Process**
Expand Down Expand Up @@ -222,6 +250,7 @@ Builds from the `main` branch are published as development images using `edge` a
- [x] Support Magisk
- [x] Adding web interface of [scrcpy](https://github.com/Shmayro/ws-scrcpy-docker)
- [x] Redirect all logs to container stdout/stderr
- [x] Optional host GPU (`GPU_MODE=host`)

## 🐞 **Troubleshooting**

Expand Down Expand Up @@ -256,6 +285,12 @@ Builds from the `main` branch are published as development images using `edge` a
Should show: `x86_64,x86,arm64-v8a,armeabi-v7a,armeabi`
- If you enabled `ARM_TRANSLATION` after the first boot, restart the container to run the install step

- **Guest GLES shows Google SwiftShader:**
- `GPU_MODE` defaults to software. Set `GPU_MODE=host` and mount `/dev/dri`.
- Check the container can see the render node: `docker exec dockerify-android ls -l /dev/dri`.
- Confirm logs contain `Starting headless Xorg` and `Graphics Adapter ... Mesa`, not `llvmpipe`.
- Rebuild the image after this change (`docker compose build`); Mesa/Xorg are not in older images.

- **Emulator Not Starting:**
- **Check Container Logs:**

Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,13 @@ services:
ROOT_SETUP: 0 # set to 1 to enable rooting
GAPPS_SETUP: 0 # set to 1 to install PICO GAPPS
ARM_TRANSLATION: 1 # set to 1 to enable ARM translation (allows ARM64 apps)
# GPU_MODE: host # use host GPU (needs /dev/dri below); default is swiftshader_indirect
privileged: true
devices:
- /dev/kvm
# - /dev/dri:/dev/dri # required for GPU_MODE=host
# group_add:
# - "44" # host GID of the group that owns /dev/dri (often 'video' or 'render')

scrcpy-web:
container_name: scrcpy-web
Expand Down
89 changes: 89 additions & 0 deletions egl-gbm-hook.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#define _GNU_SOURCE
#include <dlfcn.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

typedef void *EGLDisplay;
typedef void *EGLNativeDisplayType;
typedef int EGLint;
typedef unsigned int EGLenum;
#define EGL_NO_DISPLAY ((EGLDisplay)0)
#define EGL_PLATFORM_GBM_KHR 0x31D7

struct gbm_device;

static void hooklog(const char *msg) {
FILE *f = fopen("/tmp/egl-hook.log", "a");
if (!f)
return;
fprintf(f, "%s\n", msg);
fclose(f);
}

static void *real_from(const char *lib, const char *sym) {
void *h = dlopen(lib, RTLD_NOLOAD | RTLD_NOW);
if (!h)
h = dlopen(lib, RTLD_NOW);
return h ? dlsym(h, sym) : NULL;
}

static EGLDisplay gbm_display(void) {
static EGLDisplay dpy = EGL_NO_DISPLAY;
static int inited;
struct gbm_device *(*gbm_create_device)(int);
EGLDisplay (*get_plat)(EGLenum, void *, const EGLint *);
struct gbm_device *gbm;
int fd;
char buf[192];

if (inited)
return dpy;
inited = 1;

hooklog("gbm_display()");
fd = open("/dev/dri/renderD128", O_RDWR);
if (fd < 0)
fd = open("/dev/dri/card0", O_RDWR);

gbm_create_device = real_from("libgbm.so.1", "gbm_create_device");
get_plat = real_from("libEGL.so.1", "eglGetPlatformDisplayEXT");
if (!get_plat)
get_plat = real_from("libEGL.so.1", "eglGetPlatformDisplay");

if (!gbm_create_device || !get_plat || fd < 0) {
snprintf(buf, sizeof(buf), "missing gbm/egl/fd fd=%d gbm=%p get=%p",
fd, (void *)gbm_create_device, (void *)get_plat);
hooklog(buf);
return EGL_NO_DISPLAY;
}

gbm = gbm_create_device(fd);
dpy = get_plat(EGL_PLATFORM_GBM_KHR, gbm, NULL);
snprintf(buf, sizeof(buf), "using GBM fd=%d gbm=%p dpy=%p", fd, (void *)gbm, (void *)dpy);
hooklog(buf);
fprintf(stderr, "egl-gbm-hook: %s\n", buf);
return dpy;
}

EGLDisplay eglGetDisplay(EGLNativeDisplayType native) {
(void)native;
hooklog("eglGetDisplay");
return gbm_display();
}

void *eglGetProcAddress(const char *name) {
void *(*real)(const char *);

if (name && strcmp(name, "eglGetDisplay") == 0)
return (void *)eglGetDisplay;

real = real_from("libEGL.so.1", "eglGetProcAddress");
return real ? real(name) : NULL;
}

__attribute__((constructor)) static void init_hook(void) {
hooklog("hook loaded");
}
70 changes: 67 additions & 3 deletions start-emulator.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ CONFIG_FILE="/data/android.avd/config.ini"
update_config() {
local key="$1"
local value="$2"
if grep -q "^$key=" "$CONFIG_FILE"; then
sed -i "s/^$key=.*/$key=$value/" "$CONFIG_FILE"
if grep -qE "^${key}[[:space:]]*=" "$CONFIG_FILE"; then
sed -i "s/^${key}[[:space:]]*=.*/${key}=${value}/" "$CONFIG_FILE"
else
echo "$key=$value" >> "$CONFIG_FILE"
fi
Expand All @@ -37,5 +37,69 @@ if [ -f "$CONFIG_FILE" ]; then
fi
fi

# Default stays software (historical -gpu swiftshader_indirect).
# Set GPU_MODE=host and pass /dev/dri to use the host GPU.
case "${GPU_MODE:-swiftshader_indirect}" in
swiftshader|software) GPU_MODE=swiftshader_indirect ;;
"") GPU_MODE=swiftshader_indirect ;;
esac

if [ "$GPU_MODE" = "host" ] && [ ! -e /dev/dri/renderD128 ] && [ ! -e /dev/dri/card0 ]; then
echo "GPU_MODE=host requested but no /dev/dri render node; falling back to swiftshader_indirect"
GPU_MODE=swiftshader_indirect
fi

if [ -f "$CONFIG_FILE" ]; then
update_config "hw.gpu.enabled" "yes"
update_config "hw.gpu.mode" "$GPU_MODE"
fi

if [ "$GPU_MODE" = "host" ]; then
# Mesa DRI is a system library; the emulator's bundled libc++ will not load it.
export ANDROID_EMULATOR_USE_SYSTEM_LIBS="${ANDROID_EMULATOR_USE_SYSTEM_LIBS:-1}"
export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}"
export LIBGL_ALWAYS_SOFTWARE=0
export EGL_PLATFORM="${EGL_PLATFORM:-gbm}"
unset GALLIUM_DRIVER
unset MESA_LOADER_DRIVER_OVERRIDE
if [ -z "$VK_ICD_FILENAMES" ] && [ -f /usr/share/vulkan/icd.d/intel_icd.x86_64.json ]; then
export VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/intel_icd.x86_64.json
fi
# gfxstream still opens X11. Prefer modesetting Xorg on /dev/dri/card0 so
# GLX is Mesa iris; Xvfb is llvmpipe-only fallback. Viewing stays on scrcpy.
export DISPLAY="${DISPLAY:-:99}"
disp_num="${DISPLAY#:}"
display_live() {
[ -S "/tmp/.X11-unix/X${disp_num}" ] || return 1
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1
}
if ! display_live; then
rm -f "/tmp/.X11-unix/X${disp_num}"
mkdir -p /tmp/.X11-unix /var/log
xorg_conf="${XORG_HEADLESS_CONF:-/etc/X11/xorg-headless.conf}"
if command -v Xorg >/dev/null 2>&1 && [ -e /dev/dri/card0 ] && [ -f "$xorg_conf" ]; then
Xorg "$DISPLAY" -config "$xorg_conf" -ac -noreset -nolisten tcp -logfile /tmp/xorg.log >/tmp/xorg-stdout.log 2>&1 &
echo "Starting headless Xorg $DISPLAY on /dev/dri/card0 (modesetting/glamor)"
else
Xvfb "$DISPLAY" -screen 0 "${XVFB_SCREEN:-1280x720x24}" +extension GLX +extension RANDR +extension RENDER -ac -nolisten tcp >/tmp/xvfb.log 2>&1 &
echo "Starting Xvfb $DISPLAY (software GL fallback)"
fi
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
display_live && break
sleep 0.25
done
fi
if ! display_live; then
echo "Display $DISPLAY did not come up; host GPU GLX will fail"
fi
HOOK="${EGL_GBM_HOOK:-/usr/local/lib/libegl-gbm-hook.so}"
if [ -f "$HOOK" ]; then
export LD_PRELOAD="${HOOK}${LD_PRELOAD:+:$LD_PRELOAD}"
echo "Using EGL GBM hook on /dev/dri/renderD128"
fi
fi

echo "Starting emulator GPU_MODE=${GPU_MODE} dri=$(ls /dev/dri 2>/dev/null | tr '\n' ' ')"

# Start the emulator with the appropriate ramdisk.img
/opt/android-sdk/emulator/emulator -avd android -nojni -netfast -writable-system -no-window -no-audio -no-boot-anim -skip-adb-auth -gpu swiftshader_indirect -no-snapshot -no-metrics $RAMDISK -qemu -m ${RAM_SIZE:-4096}
/opt/android-sdk/emulator/emulator -avd android -nojni -netfast -writable-system -no-window -no-audio -no-boot-anim -skip-adb-auth -gpu "$GPU_MODE" -no-snapshot -no-metrics $RAMDISK -qemu -m ${RAM_SIZE:-4096}
44 changes: 44 additions & 0 deletions xorg-headless.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Headless Xorg for GPU_MODE=host. Binds Mesa iris/amdgpu/nouveau to
# /dev/dri/card0 even when no monitor is connected. Not used for viewing;
# the Android screen is still accessed via scrcpy / scrcpy-web.
Section "ServerFlags"
Option "AllowEmptyInitialConfiguration" "true"
Option "AutoAddGPU" "false"
Option "DontVTSwitch" "true"
Option "DontZap" "true"
EndSection

Section "ServerLayout"
Identifier "Layout0"
Screen 0 "Screen0"
Option "AutoAddDevices" "false"
Option "AutoAddGPU" "false"
EndSection

Section "Device"
Identifier "Card0"
Driver "modesetting"
Option "AccelMethod" "glamor"
Option "kmsdev" "/dev/dri/card0"
Option "SWcursor" "true"
EndSection

Section "Monitor"
Identifier "Monitor0"
HorizSync 30-100
VertRefresh 50-70
Modeline "1280x720_60.00" 74.48 1280 1336 1472 1664 720 721 724 746 -HSync +Vsync
Option "PreferredMode" "1280x720_60.00"
EndSection

Section "Screen"
Identifier "Screen0"
Device "Card0"
Monitor "Monitor0"
DefaultDepth 24
SubSection "Display"
Depth 24
Modes "1280x720"
Virtual 1280 720
EndSubSection
EndSection