A native Wayland compositor for Android, packaged as an Android library.
Piperella is the Wayland server that lets Linux/Windows graphical clients run on
Android. It is built to host Steam Big Picture + Wine + DXVK/VKD3D-Proton on
ARM64 (via FEX), rendering client surfaces straight into an Android
SurfaceView through EGL/GLES3 — with a zero-copy DMA-BUF path so GPU-rendered
frames never touch the CPU.
It ships as a drop-in Gradle module: add the :piperella-compositor project to
your app, construct a CompositorEngine, and call start(). The heavy lifting
lives in a C++ NDK core (libpiperella_compositor.so) compiled by Gradle/CMake;
the Kotlin layer is a thin, lifecycle-aware bridge over it.
It is a standalone, reusable library with no dependency on any particular
host application — the examples in examples/ are ordinary
Android apps that consume it exactly the way yours would.
Target: Android 16+ (
minSdk = targetSdk = 36),arm64-v8aonly. SeeDOCUMENTATION.mdfor the complete technical reference.
- What it does
- Architecture at a glance
- Feature overview
- Wayland protocols
- Prerequisites
- Integration & build — complete walkthrough
- Kotlin API
- Permissions
- Project structure
- Troubleshooting
- Author
- Acknowledgement
- License
A Wayland compositor is the display server that owns the screen and arbitrates between graphical clients. On a normal Linux desktop that role is filled by something like GNOME's Mutter, KDE's KWin, or wlroots-based compositors. On Android there is no Wayland — only SurfaceFlinger and the Android view system.
Piperella bridges the two worlds:
- It runs a real
libwayland-serverinstance inside an Android app, listening on a Unix socket inside the app's private data directory. - Wayland-native clients — most importantly Wine's
winewayland.drv, plus Steam and any native Wayland app — connect to that socket as if they were running on a Linux desktop. - Client buffers (CPU
wl_shmor GPUdmabuf) are composited with EGL/GLES3 and presented into an AndroidSurfaceViewoverlay, paced byChoreographerhardware vsync. - Input (touch, hardware keyboard, mouse with pointer-lock, gamepad, IME) flows back to the clients through the Wayland seat.
The result is that a Windows game launched through Wine + DXVK renders on the phone's Adreno GPU, with frames travelling GPU → dmabuf → EGLImage → SurfaceView without a single CPU copy.
┌────────────────────────────┐
│ Android app (Activity) │ engine.start(1920, 1080) …
│ • your UI / launcher │
└──────────────┬─────────────┘
│ CompositorEngine(activity)
▼
┌────────────────────────────┐
│ Kotlin library layer │ CompositorEngine
│ • SurfaceView overlay │ Choreographer vsync pacing
│ • input / IME / services │ System.loadLibrary("piperella_compositor")
└──────────────┬─────────────┘
│ JNI (CompositorEngine.native*, SurfaceProvider.nativeDispatch*)
▼
┌────────────────────────────┐
│ C++ compositor core │ libpiperella_compositor.so
│ • wl_display + epoll thread │ ~30 Wayland globals
│ • EGL/GLES3 renderer │ shm + dmabuf import paths
│ • scene graph, seat, audio │ AAudio sink, ADPF, atrace
└──────────────┬─────────────┘
│ EGL → GLES3 → ANativeWindow
▼
┌────────────────────────────┐ Wayland wire protocol (Unix socket)
│ Android SurfaceView │ ◄──── Wine / Steam / native Wayland clients
└────────────────────────────┘
Two threads carry the compositor: a dispatch thread runs the Wayland event
loop on epoll (so clients are serviced the instant a socket becomes readable),
while rendering happens on Choreographer vsync on the worker thread. The
two are decoupled so commit-to-composite latency isn't gated by the frame clock.
The host app's UI thread is never blocked by EGL.
- One Gradle module — add
:piperella-compositor, construct aCompositorEngine, callstart(). - Lifecycle-aware — surface create/change/destroy callbacks and vsync arming/disarming are handled inside the engine.
- Self-contained AAR — the cross-built Wayland and xkbcommon prebuilts are bundled into the artifact, so consumers don't have to know about them.
- EGL/GLES3 renderer with a config-fallback ladder (10-bit → 8-bit RGBA →
no-accel → ES2) and the 1×1 pbuffer
eglMakeCurrentworkaround for Adreno/Mali. wl_shmpath — damage-awareglTexSubImage2D, GL texture cached per surface, reallocated only when buffer dimensions change.- DMA-BUF path —
zwp_linux_dmabuf_v1v4 imported asEGLImageviaEGL_EXT_image_dma_buf_import, sampled directly. Zero CPU copies. - Damage tracking — per-surface damage unioned to a single screen rect and
presented with
eglSwapBuffersWithDamageKHR; idle frames are skipped. - HDR output — BT.2020 PQ color-space toggle on the EGL surface /
ANativeWindow, with EDID-like colorimetry metadata. - VRR / refresh handover — queries
ANativeWindowframe rate; re-emitswl_output::modeon refresh changes (120 Hz LTPO friendly).
- Real multi-toplevel scene graph — Wine creates one Wayland surface per HWND, so dialogs, popups, and menus are all first-class.
- Subsurface tree with spec-correct double-buffered placement and sync/desync semantics.
- Window awareness — tracks
app_id, title, PID, geometry, focus, and z-order; exposed to JS viagetWindowList().
wl_seatv9 — keyboard (xkbcommon, memfd keymap), pointer, touch.- Pointer lock + relative motion —
pointer-constraints/relative-pointerfor FPS mouselook (required by Steam/Wine from launch). - Hardware keyboard —
AccessibilityServicecaptures global evdev scancodes before the IME swallows them. - Gamepad — up to 4 controllers via a custom
zwp_gamepad_manager_v1protocol, fed fromAInputEventaxes/buttons. - IME bridge — invisible
EditText+TextWatcherfeedszwp_text_input_v3commit/preedit strings to the focused client. - Scroll — two-finger and wheel scroll, version-aware (
axis_value120).
- AAudio output engine with an MPSC ring buffer, plus a virtual ALSA
socket sink (
audio.sock) where FEX/Wine clients write raw S16LE 48 kHz stereo PCM that the engine mixes and plays.
- Foreground service (
mediaPlayback) keeps the compositor + GPU context alive when backgrounded; surface lifecycle survives suspend/resume by preserving thewl_displayand reconnecting EGL. - ADPF — Android Dynamic Performance Framework hints for thermal/perf tuning.
- atrace — systrace instrumentation for profiling the render loop.
- Notification bridge — captures Android notifications via a
NotificationListenerServicefor in-session overlay display. - 16 KB ELF page alignment for every shipped
.so(Android 15+ requirement).
All protocol glue is generated with wayland-scanner on the host and committed
to android/src/main/cpp/protocols/. The compositor advertises roughly 30
globals:
| Category | Protocols |
|---|---|
| Core | wl_compositor v6, wl_subcompositor, wl_shm, wl_output v4, wl_seat v9, wl_data_device_manager |
| Shell | xdg_wm_base v6 (toplevel + popup), zxdg_decoration_manager_v1, xdg_wm_dialog_v1, zxdg_output_manager_v1 |
| Buffers / GPU | zwp_linux_dmabuf_v1 v4 (+ feedback), wp_linux_drm_syncobj_v1, wp_single_pixel_buffer_manager_v1 |
| Presentation | wp_presentation, wp_viewporter, wp_fractional_scale_manager_v1, wp_tearing_control_manager_v1, wp_alpha_modifier_v1, wp_content_type_manager_v1 |
| Input | zwp_pointer_constraints_v1, zwp_relative_pointer_manager_v1, zwp_pointer_gestures_v1, wp_cursor_shape_manager_v1, zwp_keyboard_shortcuts_inhibit_manager_v1, zwp_text_input_manager_v3, zwp_gamepad_manager_v1 (custom) |
| Idle / misc | zwp_idle_inhibit_manager_v1, ext_idle_notifier_v1, xdg_activation_v1, xdg_system_bell_v1 |
See DOCUMENTATION.md for
per-protocol versions, status, and the source file that implements each one.
| Tool | Version | Notes |
|---|---|---|
| JDK | 17 | Required by the Android Gradle Plugin |
| Android SDK | 36 | compileSdk / targetSdk |
| Android NDK | r29 (29.0.14206865) |
Required for 16 KB page support |
| Vulkan | 1.1 | Device requirement for DXVK/VKD3D-Proton clients |
The C++ core links against libwayland-server.so and libxkbcommon.so
cross-built for the NDK, plus generated protocol headers. These prebuilts live
in android/src/main/cpp/prebuilts/arm64-v8a/ and
android/src/main/cpp/protocols/ and are committed to the repo. They are
produced by two host scripts (meson/ninja cross-build + wayland-scanner) and
only need regenerating when bumping the Wayland libraries or adding a protocol.
The scripts are self-documented; see the inline comments in
scripts/build-libwayland-android.sh.
This section is written to be followed top-to-bottom with no prior context. It covers cloning, producing the native prebuilts, adding the library to an Android app, and driving it from Kotlin.
The compositor is a single Gradle module. Two things must be wired up:
| Artifact | What it is | Where it plugs in |
|---|---|---|
| Android library module | android/ (Kotlin + C++ NDK core) |
Gradle include(":piperella-compositor") + implementation(project(...)) |
CompositorEngine |
the Kotlin entry point | constructed with your Activity, start()/stop() from your UI |
Everything else — the Wayland server, the EGL renderer, the input pipeline, the
four background services — is internal to the module and needs no wiring. The
module ships its own AndroidManifest.xml, which Gradle merges into your app.
Shortcut:
setup.shautomates the toolchain (system packages, Android SDK/NDK r29, prebuilt verification). Run./setup.shfor the full install,./setup.sh --helpfor options (--skip-android,--with-wayland-host-deps,--write-rc, …). It is idempotent and writes asetup.envyou cansource. The manual steps below document what it does and let you do it piecemeal.
git clone <repo-url> Piperella-Wayland-Compositor
cd Piperella-Wayland-Compositor
# One-shot toolchain install (or follow the manual steps below):
./setup.sh
source setup.envConfirm the two committed native prebuilts exist — the Android build fails
fast with a CMake FATAL_ERROR if they are missing:
ls android/src/main/cpp/prebuilts/arm64-v8a/lib/ # expect libwayland-server.so, libxkbcommon.so
ls android/src/main/cpp/protocols/ # expect *-protocol.c / *-protocol.hIf they are present (they are committed to the repo), skip Step 1.
The C++ core links three cross-compiled dependency libraries —
libffi, libwayland-server (+ client/cursor/egl), and libxkbcommon —
which are committed to android/src/main/cpp/prebuilts/arm64-v8a/. You only
need to rebuild them when first vendoring, restoring missing prebuilts, or
bumping a version. (setup.sh runs this automatically if the prebuilts are
absent and the host deps are present.)
# Cross-builds libffi 3.4.6, Wayland 1.24.0, libxkbcommon 1.7.0 for the NDK
# and installs them into android/src/main/cpp/prebuilts/arm64-v8a/{lib,include}.
./scripts/build-libwayland-android.sh
# Options (all env-overridable; defaults shown):
# API=36 ./scripts/build-libwayland-android.sh # Android API level
# KEEP_BUILD=1 ./scripts/build-libwayland-android.sh # keep the build tree
# WAYLAND_VERSION=1.24.0 XKBCOMMON_VERSION=1.7.0 LIBFFI_VERSION=3.4.6 \
# ABI=arm64-v8a TRIPLE=aarch64-linux-android ./scripts/build-libwayland-android.shsetup.sh exposes the same pattern — every pinned version is an env var with a
default, e.g. NDK_VERSION=… ANDROID_PLATFORM=… BUILD_TOOLS_VERSION=… CMAKE_VERSION=… ./setup.sh.
Host requirements (install via ./setup.sh --with-wayland-host-deps, or
manually sudo apt install meson ninja-build make curl git libwayland-bin):
| Tool | Why |
|---|---|
| Android NDK r29 | the cross-compiler; located via $ANDROID_NDK_HOME / $NDK_HOME / $ANDROID_HOME/ndk/* |
meson + ninja |
build system for Wayland and libxkbcommon |
make + curl |
libffi (autotools, downloaded release tarball) |
git |
clones the Wayland / libxkbcommon sources at the pinned tags |
wayland-scanner (host) |
required even with -Dscanner=false: the build machine generates the protocol headers for the cross-built libraries (apt: libwayland-bin) |
The script writes a meson NDK cross-file with 16 KB ELF page alignment
(-Wl,-z,max-page-size=16384, mandatory on Android 15+) and builds
libxkbcommon with -Denable-xkbregistry=false (xkbregistry pulls in libxml2,
unavailable in the bionic cross-build).
This builds dependency
.sofiles only. The compositor's own libraries —libpiperella_compositor.soandlibinputqueue.so— are built by Gradle/CMake/NDK during the APK build (Step 7), not here.
Protocol headers. The generated Wayland protocol C/headers in
android/src/main/cpp/protocols/are also committed. If you add or bump a protocol XML, regenerate them withwayland-scanner(server-header+private-code) per the list inCMakeLists.txt'sPROTOCOL_SOURCES.
your-app/settings.gradle.kts:
include(":piperella-compositor")
project(":piperella-compositor").projectDir =
file("../Piperella-Wayland-Compositor/android")
// ^ adjust the relative path to point at this repo's android/ directoryyour-app/app/build.gradle.kts:
android {
// REQUIRED: the input pipeline ships 30 AIDL files that must be compiled.
buildFeatures { aidl = true }
}
dependencies {
implementation(project(":piperella-compositor"))
}You do not edit the app's AndroidManifest.xml: the module ships its own
manifest (permissions + the four services) which Gradle merges automatically.
You also don't call System.loadLibrary — CompositorEngine loads
libpiperella_compositor.so itself.
Toolchain pins the module requires (already set in its own
build.gradle.kts, but your app must be compatible):compileSdk = 36,minSdk = 36, NDK29.0.14206865,abiFilters = ["arm64-v8a"],jvmTarget = 17. Make sureANDROID_NDK_HOME/ANDROID_HOMEare exported and the NDK r29 + SDK 36 are installed.
Construct one CompositorEngine per Activity and start it. The engine creates
its own SurfaceView overlay, so there is no layout work to do:
import com.piperella.compositor.CompositorEngine
class MainActivity : Activity() {
private lateinit var engine: CompositorEngine
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
engine = CompositorEngine(this)
// Fires once the Wayland socket is accepting connections.
engine.onCompositorReady = { Log.i("App", "socket ready") }
engine.start(1920, 1080)
}
override fun onDestroy() {
engine.stop()
super.onDestroy()
}
}See the API section for the full surface. A complete working
consumer lives in examples/test-app/.
# Debug build + install on a connected arm64 device
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apkWhat happens during the build:
- Gradle builds the
:piperella-compositorAAR — Kotlin + CMake/NDK compiling the C++ core intolibpiperella_compositor.so(andlibinputqueue.so), linking the committedlibwayland-server.so/libxkbcommon.so. - AIDL files generate the binder C++/Java for the input pipeline.
- Everything is packaged into the APK under
lib/arm64-v8a/.
# Watch compositor logs
adb logcat --pid=$(adb shell pidof <app.id>) \
| grep -iE "compositor|wayland|piperella|egl|surface|audio|adpf"
# Confirm the Wayland socket was created after start()
adb shell ls -la /data/data/<app.id>/files/tmp/
# expect: piperella-wayland-0 (srwxr-xr-x); audio.sock appears once audio startsFor hardware keyboards, the user must enable the accessibility service once in
Settings → Accessibility → Piperella Compositor (Android requires a manual
opt-in for AccessibilityService).
Wayland clients (Wine, Steam, native apps) then connect by pointing
WAYLAND_DISPLAY=piperella-wayland-0 and
XDG_RUNTIME_DIR=/data/data/<app.id>/files/tmp at the socket. Use
WAYLAND_DEBUG=1 on the client to trace the wire protocol.
| Artifact | Produced by | Location |
|---|---|---|
libpiperella_compositor.so |
CMake/NDK | APK lib/arm64-v8a/ |
libinputqueue.so |
CMake/NDK (AIDL input bridge) | APK lib/arm64-v8a/ |
libwayland-server.so, libxkbcommon.so |
committed prebuilts | APK lib/arm64-v8a/ |
piperella-compositor.aar |
Gradle (:piperella-compositor) |
android/build/outputs/aar/ |
⚠️ AssignonCompositorReadybefore callingstart(). The callback fires once, when the Wayland socket begins accepting connections, and is not replayed — assigning it afterstart()may miss it.
import com.piperella.compositor.CompositorEngine
val engine = CompositorEngine(activity)
// 1. Listen first.
engine.onCompositorReady = { Log.i(TAG, "Wayland socket ready") }
// 2. Start the compositor.
engine.start(1920, 1080)
val running = engine.isRunning // Boolean
val ready = engine.isSocketReady // socket file exists on disk
val windows = engine.getWindowListJson() // JSON array, see below
val hdrOn = engine.setHdrMode(true) // BT.2020 PQ; returns whether applied
engine.stop()| Member | Description |
|---|---|
start(w, h) |
Start the compositor at the given resolution and attach the SurfaceView overlay. |
stop() |
Stop, disarm vsync, and release native resources. |
isRunning |
true while a native compositor handle is held. |
isSocketReady |
true once the Wayland socket exists in the app's tmp directory. |
getWindowListJson() |
JSON array describing all Wayland toplevels (see below). |
setHdrMode(enabled) |
Toggle HDR output (BT.2020 PQ); returns whether it applied. |
onCompositorReady |
Callback invoked once when the socket is ready. |
onSurfaceCreated/Changed/Destroyed |
Surface lifecycle hooks, normally driven by the engine's own overlay. |
stopVsync() |
Disarm the Choreographer callback without tearing the compositor down. |
getWindowListJson() returns a JSON array whose objects carry:
The module's AndroidManifest.xml declares the Android-side permissions and
services it needs, and Gradle merges them into your app:
| Permission | Purpose |
|---|---|
WAKE_LOCK |
Keep the CPU/GPU active during rendering |
FOREGROUND_SERVICE / FOREGROUND_SERVICE_MEDIA_PLAYBACK |
Survive backgrounding while preserving the GPU context |
BIND_ACCESSIBILITY_SERVICE |
Global hardware-keyboard scancode capture |
BIND_NOTIFICATION_LISTENER_SERVICE |
Capture Android notifications for overlay |
POST_NOTIFICATIONS |
Foreground-service notification (runtime request on API 33+) |
| Service | Purpose |
|---|---|
ForegroundService |
Background keepalive with a compositor.alive heartbeat |
SurfaceService |
AIDL-bound input forwarding binder |
NotificationListenerService |
Captures Android notifications |
KeyCaptureService |
AccessibilityService for hardware keyboard |
Piperella-Wayland-Compositor/
├── README.md # this file
├── DOCUMENTATION.md # complete technical reference
├── setup.sh # one-shot toolchain installer
├── scripts/ # host-side cross-build helpers
├── examples/ # standalone apps consuming the library
├── tools/ # auxiliary host utilities
└── android/
├── build.gradle.kts # AAR module (SDK 36, NDK r29, arm64-v8a)
├── proguard-rules.pro
└── src/main/
├── AndroidManifest.xml
├── aidl/ # 30 vendored AIDL files (input forwarding)
├── java/com/piperella/compositor/
│ ├── CompositorEngine.kt # public API, JNI lifecycle + Choreographer
│ ├── SurfaceProvider.kt # SurfaceView overlay + input
│ ├── SurfaceService.kt # AIDL input binder
│ ├── ForegroundService.kt # background keepalive
│ ├── KeyCaptureService.kt # AccessibilityService
│ ├── TextInputBridge.kt # IME bridge
│ └── NotificationListenerService.kt
├── java/com/xtr/tinywl/SurfaceServiceKt.kt # input-queue JNI shim
├── res/
└── cpp/ # C++ NDK compositor core
├── CMakeLists.txt
├── labwc.{h,cpp} # wl_display, globals, scene graph
├── egl_renderer.{h,cpp} # EGL/GLES3 renderer
├── jni_bridge.cpp # JNI ↔ native glue
├── surface.cpp scene.cpp # surface lifecycle / scene render
├── xdg_shell.cpp xdg_dialog.cpp
├── seat.cpp text_input.cpp # input + IME
├── dmabuf.cpp syncobj.cpp # GPU buffer import + fences
├── presentation.cpp viewporter.cpp fractional_scale.cpp
├── data_device.cpp activation.cpp
├── gamepad.cpp pointer_gestures.cpp cursor_shape.cpp
├── tearing_control.cpp content_type.cpp alpha_modifier.cpp
├── idle_inhibit.cpp ext_idle_notify.cpp
├── keyboard_shortcuts_inhibit.cpp single_pixel_buffer.cpp
├── xdg_system_bell.cpp
├── audio_engine.{h,cpp} # AAudio + virtual ALSA sink
├── notification_bridge.{h,cpp}
├── adpf.{h,cpp} atrace.{h,cpp}
├── input-queue.cpp # vendored input forwarding
├── damage.h
├── prebuilts/arm64-v8a/ # libwayland-server + libxkbcommon
└── protocols/ # generated protocol C/headers
A full per-file breakdown is in
DOCUMENTATION.md.
| Symptom | Fix |
|---|---|
libwayland-server.so not found (CMake fatal error) |
Run ./scripts/build-libwayland-android.sh at the repo root to cross-build the prebuilts. |
UnsatisfiedLinkError: libpiperella_compositor.so |
Check CMake outputs; confirm NDK r29 and arm64-v8a. |
inputqueue library not built (CMake warning) |
Ensure buildFeatures { aidl = true } and that aidl_source_output_dir resolves. |
| Hardware keys not captured | Enable the service in Android Settings → Accessibility → Piperella Compositor. |
SurfaceView not visible |
The overlay sets Z-order-on-top; check view ordering in your activity. |
Vulkan 1.1 required |
The compositor uses EGL/GLES3, but DXVK/VKD3D-Proton clients require a Vulkan 1.1 device. |
Piperella Wayland Compositor is written and maintained by Yassine Koubry.
This work was done with the help of Claude.
GPL-3.0 — see LICENSE.
The AIDL binder input path (input-queue.cpp, com/xtr/tinywl/) is derived from
wlroots-android-bridge, which
is GPL-3.0, so this project is distributed under the same terms. Applications
that link this library must therefore also be released under GPL-3.0.
Third-party components redistributed here — Wayland, libxkbcommon, libffi, the
AOSP AIDL interfaces, Weston's simple-egl, vkcube, zstd, AdrenoTools, Mesa
Turnip, DXVK and the bundled fonts — are covered by their own licenses. See
THIRD_PARTY_NOTICES.md and licenses/.
{ "appId": "steam", // Wayland app_id "title": "Steam", // Window title "pid": 12345, // Client process ID "x": 0, "y": 0, // Position in compositor space "w": 1920, "h": 1080,// Size in pixels "focused": true, // Has input focus "zOrder": 3 // Depth; higher = closer to viewer }