Run Android APKs natively on macOS. No virtualization, no Docker, no emulator.
PolyRun loads an Android APK and runs it as a native macOS process. The app's Java and Kotlin code executes as JIT-compiled ARM64 through OpenJDK. Android framework calls (Activity lifecycle, Resources, OpenGL ES, touch input, and the rest) are bridged to Cocoa, Metal, and ANGLE.
Status: v1.0. Flappy Bird is fully playable at a locked 60 FPS. The game boots from an unmodified APK, renders the title screen via ANGLE on Metal, takes mouse-as-touch input, and runs the gameplay loop end to end.
| App | Version | What works |
|---|---|---|
Flappy Bird (com.dotgears.flappybird) |
1.3 (build 4, minAPI 8) | Title screen renders, tap-to-flap, scoring, game-over flow. Locked 60 FPS. |
The runtime grows incrementally. Each missing framework piece in a new APK is one small Java class away from working. The Flappy Bird path exercised most of the AndEngine surface and a wide slice of the framework, so other AndEngine and pure-Java games are likely to boot. If you try one and it falls over, the log tells you which class or method is missing next.
APK file
|
v
apk_unpacker (C++)
|--> dex2jar -> classes.jar (Dalvik converted to JVM bytecode)
|--> apktool -> decoded AndroidManifest.xml + resources
v
JNI_CreateJavaVM (HotSpot, in-process, native ARM64)
classpath = [android-framework.jar, polyrun-loader.jar, classes.jar]
v
com.polyrun.loader.Boot.run
|--> Manifest.parse (find launchable activity)
|--> ActivityHost.start (instantiate, run lifecycle)
v
[NSApp run] <-- Java callbacks fire from MTKView's display link,
Cocoa mouse events, Handler queue drains, etc.
There are four real layers, all native ARM64:
- OpenJDK 21. HotSpot runs in-process and JIT-compiles every app method on first call. There is no interpreter loop in the hot path.
- Java framework reimplementation under
runtime_v2/java/. Plain Java sources forandroid.app.Activity,android.opengl.GLSurfaceView,android.view.MotionEvent, and the rest of what an APK reaches for. The same JIT runs them. - JNI bridges in
runtime_v2/src/native_methods.cpp.GLES20.*calls forward to ANGLE'slibGLESv2.dylib. Cocoa events dispatch back into Java. Roughly 50 GLES entry points are wired. - ANGLE Metal backend. Translates GLES2 to Metal. The drawable is a
CAMetalLayersublayer over the MTKView's own layer, presented at vsync.
There is no virtualization. HotSpot is a JIT compiler, not a virtual machine in the QEMU sense. The runtime never simulates a Linux kernel or an Android system service.
MTKView.drawInMTKView (display link, 60Hz)
|
|-- AngleBeginFrame (eglMakeCurrent on the ANGLE sublayer)
|
|-- GLSurfaceView.polyrunDrawFrame (JNI -> Java)
| |
| `-- AndEngine renderer.onDrawFrame
| ~8 sprites/frame on title screen, ~9ms wall time
|
`-- AngleEndFrame (eglSwapBuffers, presents to CAMetalLayer)
Buffer uploads (glBufferData, glTexImage2D, glVertexAttribPointer) take a java.nio.Buffer directly. The native side reads through GetDirectBufferAddress for zero-copy on direct buffers, or pins the backing array on heap buffers. No per-call element loops.
The original PolyRun runtime, still on disk under runtime/, was a hand-written Dalvik interpreter (~110 opcodes) plus a 6,400-line framework_bridge.cpp that imitated Android API behavior in C++. That runtime got Flappy Bird's title screen rendering visually. It worked. The problem was scaling: every new APK needed hundreds more bridge entries written by hand in C++, and the interpreter ate frame time the JIT didn't have to.
We replaced it with the OpenJDK + Java framework approach (everything described above) for two reasons:
- HotSpot does the bytecode work for free. No interpreter to maintain. App methods JIT to native ARM64 the first time they run, and run at native speed after that.
- The Android framework reimpl is plain Java. A new framework class is one short
.javafile. Far less code than mimicking it in C++, and much easier to read.
The launcher offers v1 as Native DEX (deprecated) in the runtime picker. It is left intact so v0.6's known-good rendering of the Flappy Bird title screen stays reproducible. All new work goes into v2 (the headline runtime above).
Requirements:
- macOS 14 or newer, Apple Silicon
- Xcode Command Line Tools (
xcode-select --install) - CMake 3.20+
- OpenJDK 21 (
brew install openjdk@21) - dex2jar 2.4 (
brew install dex2jar) - apktool (
brew install apktool)
One-time ANGLE build (Apple's Metal Toolchain is not part of CLT, hence the explicit download):
xcodebuild -downloadComponent MetalToolchain
scripts/build_angle.sh # ~30-60 minutes, ~25 GB pulledThen build the runtime, the launcher, and open the app:
chmod +x scripts/*.sh
scripts/rebuild_and_run_v2.shThat script wipes the v2 build dir and the launcher .app bundle, rebuilds both, and opens the launcher. The runtime picker defaults to Java (OpenJDK).
To run the binary directly without the launcher UI:
runtime_v2/build/bin/polyrun-v2 --apk /path/to/app.apkruntime_v2/ active runtime (OpenJDK + Java framework)
java/ framework reimpl + boot loader
src/ native launcher, JNI bridges, Cocoa, ANGLE glue
scripts/build_v2.sh javac -> jar -> cmake pipeline
runtime/ deprecated v1 (DEX interpreter + C++ framework bridge)
launcher/ SwiftUI launcher app
scripts/ top-level build + run wrappers
thirdparty/angle/ Google's ANGLE (gitignored, built locally)
docs/THIRD_PARTY.md license attribution for vendored components
Drag any APK onto the launcher, or pass --apk path to the binary. Most will throw at some point, and the runtime logs the exact class and method that is missing next. That log is the contributor TODO list:
runtime_v2/build/bin/polyrun-v2 --apk /path/to/app.apk 2>&1 \
| grep -iE "no implementation|UnsatisfiedLinkError|ClassNotFoundException" \
| sort -uWhat currently happens by app type:
- Pure-Java apps with simple UI (calculator, todo list, settings): likely launches with the splash or main activity visible. Specific behavior depends on which framework methods get exercised.
- AndEngine 2D games (Flappy Bird and similar): expected to boot at least to the title screen with sprites and atlases.
- Apps with Google Play Services (anything with login, ads, maps): the runtime stubs out the GMS SDK as no-ops so init flows complete. Actual Google services don't exist outside Android, so the user-facing surface for those features won't function.
- Games with native libraries (anything with
lib/<abi>/*.so): UI may run; native code paths are not loaded yet. APK.soloading via a Bionic-to-libSystem shim is on the list.
The fastest way to help: drag an APK onto the launcher, copy the log, open an issue with what failed. Each unique error is a small, well-bounded chunk of work.
Code contributions especially wanted in:
- New framework classes under
runtime_v2/java/android/.... Adding a missing class is usually one short Java file. - JNI bridges for additional
GLES20entry points orandroid.media.*audio sinks inruntime_v2/src/native_methods.cpp. - APK
.soloading. A libhybris-style Bionic shim that letsSystem.loadLibraryresolve native libraries from the APK. This unlocks anything physics-heavy or audio-heavy.
Style:
- C++17,
-Wall -Wextraclean. - Comments explain the why, not the what.
- No em dashes in code, comments, or docs (project convention).
The PolyRun launcher and runtime are MIT licensed. See LICENSE.
The vendored bionic_translation source under thirdparty/bionic_translation/ is GPL-3.0 and carries its own license file. It is not currently linked into the runtime; if and when it gets wired in for native library loading, the runtime helper process becomes GPL-3.0 by linkage and the launcher stays MIT. The boundary is the process spawn, the same pattern Steam's Proton uses to keep Steam itself proprietary while Wine remains LGPL.
Full third-party attribution: docs/THIRD_PARTY.md.
- The Android Translation Layer (ATL) project for the Linux equivalent of what PolyRun does on macOS.
- Google's ANGLE for GLES to Metal translation.
- OpenJDK for HotSpot.
- dex2jar and apktool for the APK ingest pipeline.
- AOSP's resource-format documentation in
frameworks/base/include/androidfw/ResourceTypes.h.