Back to logs
2026-03-24 androidfeatureshizuku

Porting MapXr to Android: three dead ends and one great open-source library

The Android port has been the most technically demanding part of this project, and not just because cross-platform mobile development is inherently fiddly. The real challenge was finding a way to inject keystrokes into arbitrary apps without requiring root access. Android does not make this easy by design. The OS has progressively locked down the input injection APIs over successive releases. Getting here required three distinct implementation attempts before landing on something that actually works.

Attempt 1: AccessibilityService

The first approach was AccessibilityService. It's the API Android officially exposes for keyboard-adjacent automation: screen readers, switch access, assistive tools. It can dispatch key events via performGlobalAction and send typed text via performAction(ACTION_SET_TEXT). It's well-documented, requires no root, and every Android device supports it. The obvious choice.

The problem is what it can't do. ACTION_SET_TEXT replaces the content of the focused text field. It doesn't simulate a keypress arriving at the app. There is no modifier key support. Hotkeys like Ctrl+Z or Ctrl+Tab are impossible because there's no mechanism to hold a modifier while firing another key. Navigation keys like arrow keys and Page Down work in text fields but not in games, editors, or anything that handles raw key events. The deeper problem is that AccessibilityService is built for screen reader use cases, not general-purpose keyboard emulation. MapXr needs to send arbitrary key chords to any app, and AccessibilityService can't do that.

Attempt 2: Custom input method (IME)

The second attempt was building a custom InputMethodService. Essentially a software keyboard that MapXr controls behind the scenes. An IME has access to InputConnection, which lets it inject text and some key events into the currently focused text field with decent fidelity.

This hits the same ceiling from a different direction. InputConnection only reaches the currently focused editable view. A browser, a game, a terminal app. None of these route key events through the IME at all. The IME approach works well if your goal is a smarter text input experience, but MapXr needs to be a keyboard replacement for everything. An IME that can't send Escape to a terminal or arrow keys to a game is not a keyboard replacement.

Attempt 3: ADB wireless debugging shell

The third attempt was more ambitious. Android's developer options expose a wireless debugging mode, and adb shell input keyevent can inject arbitrary key events as the shell user, bypassing all the usual input API restrictions. If MapXr could establish its own ADB connection over the local Wi-Fi interface, it could fire shell commands directly without any external tool.

This turned into a substantial engineering effort. The ADB wireless debugging pairing protocol involves SPAKE2 password-authenticated key exchange, followed by TLS negotiation using the exchanged keys. The ADB transport protocol for sending shell commands sits on top of that. None of this is formally documented. The spec is the AOSP source code.

Pairing worked. The SPAKE2 exchange completed, the device showed "mapxr@mapxr" in the Paired Devices list. The TLS connection attempt then failed with CERTIFICATE_VERIFY_FAILED. Logcat on the device showed "Invalid base64 key" for every entry in the ADB keystore loaded from our pairing. The key was structurally correct. The base64 decoded to 524 bytes, the RSA header fields matched the format exactly, and the same key format is accepted without complaint on desktop Linux. Something in the Samsung Android 16 (API 36) adbd implementation was rejecting it for a reason that can't be diagnosed without root access to the device. After exhausting every diagnostic angle available from the unprivileged side, the approach was abandoned.

Shizuku

Shizuku is an open-source project by Rikka that solves exactly this class of problem in a way the previous approaches couldn't. It uses Android's wireless debugging infrastructure (the same pairing mechanism that already works) to launch a persistent background service as the shell user. Apps that have been granted permission via Shizuku can then bind to that service over Binder IPC and make calls as if they were running as shell uid 2000, without requiring root and without needing to implement any ADB protocol themselves.

The key Android API this unlocks is InputManager.injectInputEvent(). This is the same method that adb shell input uses internally. With shell uid access, MapXr can inject any KeyEvent or MotionEvent into the global input pipeline. Modifier keys, function keys, key chords, everything.

I want to specifically thank the Shizuku team for what they've built. This is a genuinely difficult problem to solve. Navigating Android's increasingly restrictive permission model while keeping things user-friendly and not requiring root is no small feat. The library is well-designed, the documentation is clear, and the project has been actively maintained across multiple Android major versions. MapXr would not have an Android port without their work.

How the Shizuku integration works

The integration has three layers: a Shizuku UserService running as shell uid, a dispatcher singleton on the app side, and a JNI bridge to the Rust engine.

InputUserService

InputUserService is a bound service that Shizuku launches as the shell user. It implements an AIDL interface (IInputService) with two methods: injectKey(KeyEvent) and injectMotion(MotionEvent). The implementation uses reflection to call InputManagerGlobal.injectInputEvent() directly, with a fallback to the public InputManager API. Running as shell uid 2000, these calls succeed where they would be silently dropped from a normal app process.

ShizukuDispatcher

ShizukuDispatcher is a Kotlin singleton that owns the Shizuku connection lifecycle and translates mapping-core action JSON into KeyEvent and MotionEvent calls. It exposes a StateFlow<ShizukuState> that tracks whether Shizuku is installed, running, permission-granted, binding, active, or reconnecting. The UI wizard drives from that state. It polls every second and auto-advances through the setup steps as each condition is met.

The dispatcher handles the full action vocabulary: key (with complete modifier key sequencing: down all modifiers, down+up the primary key, up modifiers in reverse), key_chord, type_string (via ACTION_MULTIPLE with a per-character fallback for OEMs that drop it), mouse_click, mouse_double_click, mouse_scroll, and macro with per-step delays.

JNI bridge and background dispatch

The reason background key injection works (even when the MapXr WebView is suspended) is that the dispatch path never goes through JavaScript at all. When a BLE characteristic notification arrives from the Tap Strap, BlePlugin.onTapBytes() calls NativeBridge.processTapBytes(), which crosses the JNI boundary into Rust. The Rust pump resolves the tap bytes into actions, then calls back into Kotlin via JNI to invoke ShizukuDispatcher.dispatch(actionsJson) on the InputUserService binder. The WebView is not involved at any point in this path, so the pipeline keeps working when the app is in the background.

Setup wizard

The ShizukuSetup wizard in Settings walks through three steps: install Shizuku, start it via Wireless Debugging, and grant MapXr permission. Each step polls the dispatcher state every second and advances automatically. The user doesn't need to tap "Next". Once the InputUserService binds successfully, the wizard shows a confirmation screen and that's the full setup. After the first start, Shizuku auto-starts on every reboot via the wireless debugging daemon, so the one-time setup is genuinely one-time.

What's next

The Android implementation is complete. The APK build is working and will be included in the next release alongside the existing Linux and Windows installers. If you've been waiting to try MapXr on Android, it's almost there.