Skip to content

Repository files navigation

Piperella Wayland Compositor (Proof of concept)

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-v8a only. See DOCUMENTATION.md for the complete technical reference.


Table of Contents


What it does

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-server instance 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_shm or GPU dmabuf) are composited with EGL/GLES3 and presented into an Android SurfaceView overlay, paced by Choreographer hardware 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.


Architecture at a glance

┌────────────────────────────┐
│  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.


Feature overview

Library surface

  • One Gradle module — add :piperella-compositor, construct a CompositorEngine, call start().
  • 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.

Rendering

  • EGL/GLES3 renderer with a config-fallback ladder (10-bit → 8-bit RGBA → no-accel → ES2) and the 1×1 pbuffer eglMakeCurrent workaround for Adreno/Mali.
  • wl_shm path — damage-aware glTexSubImage2D, GL texture cached per surface, reallocated only when buffer dimensions change.
  • DMA-BUF path — zwp_linux_dmabuf_v1 v4 imported as EGLImage via EGL_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 ANativeWindow frame rate; re-emits wl_output::mode on refresh changes (120 Hz LTPO friendly).

Scene & windowing

  • 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 via getWindowList().

Input

  • wl_seat v9 — keyboard (xkbcommon, memfd keymap), pointer, touch.
  • Pointer lock + relative motion — pointer-constraints / relative-pointer for FPS mouselook (required by Steam/Wine from launch).
  • Hardware keyboard — AccessibilityService captures global evdev scancodes before the IME swallows them.
  • Gamepad — up to 4 controllers via a custom zwp_gamepad_manager_v1 protocol, fed from AInputEvent axes/buttons.
  • IME bridge — invisible EditText + TextWatcher feeds zwp_text_input_v3 commit/preedit strings to the focused client.
  • Scroll — two-finger and wheel scroll, version-aware (axis_value120).

Audio

  • 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.

Android integration

  • Foreground service (mediaPlayback) keeps the compositor + GPU context alive when backgrounded; surface lifecycle survives suspend/resume by preserving the wl_display and 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 NotificationListenerService for in-session overlay display.
  • 16 KB ELF page alignment for every shipped .so (Android 15+ requirement).

Wayland protocols

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.


Prerequisites

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

Host build prerequisites (one-time)

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.


Integration & build — complete walkthrough

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.

Mental model (read this first)

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.sh automates the toolchain (system packages, Android SDK/NDK r29, prebuilt verification). Run ./setup.sh for the full install, ./setup.sh --help for options (--skip-android, --with-wayland-host-deps, --write-rc, …). It is idempotent and writes a setup.env you can source. The manual steps below document what it does and let you do it piecemeal.

Step 0 — Clone & inspect

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.env

Confirm 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.h

If they are present (they are committed to the repo), skip Step 1.

Step 1 — (Re)build the native dependency prebuilts (only if missing or bumping versions)

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.sh

setup.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 .so files only. The compositor's own libraries — libpiperella_compositor.so and libinputqueue.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 with wayland-scanner (server-header + private-code) per the list in CMakeLists.txt's PROTOCOL_SOURCES.

Step 2 — Add the Gradle module to your app

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/ directory

your-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, NDK 29.0.14206865, abiFilters = ["arm64-v8a"], jvmTarget = 17. Make sure ANDROID_NDK_HOME/ANDROID_HOME are exported and the NDK r29 + SDK 36 are installed.

Step 3 — Drive it from Kotlin

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/.

Step 4 — Build & run

# Debug build + install on a connected arm64 device
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk

What happens during the build:

  1. Gradle builds the :piperella-compositor AAR — Kotlin + CMake/NDK compiling the C++ core into libpiperella_compositor.so (and libinputqueue.so), linking the committed libwayland-server.so / libxkbcommon.so.
  2. AIDL files generate the binder C++/Java for the input pipeline.
  3. Everything is packaged into the APK under lib/arm64-v8a/.

Step 5 — Runtime checks on device

# 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 starts

For 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.

Build artifacts & where they land

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/

Kotlin API

⚠️ Assign onCompositorReady before calling start(). The callback fires once, when the Wayland socket begins accepting connections, and is not replayed — assigning it after start() 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()

Members

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.

Window list entries

getWindowListJson() returns a JSON array whose objects carry:

{
  "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
}

Permissions

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

Project structure

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.


Troubleshooting

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.

Author

Piperella Wayland Compositor is written and maintained by Yassine Koubry.

Acknowledgement

This work was done with the help of Claude.


License

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/.

About

Native Wayland compositor for Android, shipped as a reusable Gradle library. Runs Linux/Windows graphical clients: Wine, DXVK/VKD3D-Proton, Steam, on ARM64 via FEX, compositing straight into a SurfaceView with EGL/GLES3 and a zero-copy DMA-BUF path.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Contributors

Languages