Skip to content

kksimp/PolyRun

Repository files navigation

PolyRun

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.

Known working apps

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.

How it works

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:

  1. OpenJDK 21. HotSpot runs in-process and JIT-compiles every app method on first call. There is no interpreter loop in the hot path.
  2. Java framework reimplementation under runtime_v2/java/. Plain Java sources for android.app.Activity, android.opengl.GLSurfaceView, android.view.MotionEvent, and the rest of what an APK reaches for. The same JIT runs them.
  3. JNI bridges in runtime_v2/src/native_methods.cpp. GLES20.* calls forward to ANGLE's libGLESv2.dylib. Cocoa events dispatch back into Java. Roughly 50 GLES entry points are wired.
  4. ANGLE Metal backend. Translates GLES2 to Metal. The drawable is a CAMetalLayer sublayer 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.

Per-frame render path

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.

Why we built v2 (and where the old runtime fits)

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 .java file. 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).

Build

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 pulled

Then build the runtime, the launcher, and open the app:

chmod +x scripts/*.sh
scripts/rebuild_and_run_v2.sh

That 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.apk

Repo layout

runtime_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

Trying real APKs

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 -u

What 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 .so loading via a Bionic-to-libSystem shim is on the list.

Contributing

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 GLES20 entry points or android.media.* audio sinks in runtime_v2/src/native_methods.cpp.
  • APK .so loading. A libhybris-style Bionic shim that lets System.loadLibrary resolve native libraries from the APK. This unlocks anything physics-heavy or audio-heavy.

Style:

  • C++17, -Wall -Wextra clean.
  • Comments explain the why, not the what.
  • No em dashes in code, comments, or docs (project convention).

License

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.

Acknowledgements

About

Run any app on MacOS

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors