March 4, 2026
Last updated: March 4, 2026
If you use a physical keyboard with your Android device, you've probably noticed there's no easy way to add custom key layers — like using AltGr+IJKL as arrow keys. On desktop Linux you have tools like Kanata or KMonad, but Android doesn't offer anything comparable out of the box.
In this post I'll show you how I got a full navigation layer working on Android — without root — by combining two tools:
Here's the full set of bindings:
| Combo | Result |
|---|---|
| AltGr + I / J / K / L | Up / Left / Down / Right |
| AltGr + O / P | Backspace / Delete |
| AltGr + H / ; | Home / End |
| CapsLock | Escape |
All of the above also work with Shift (for text selection) and Ctrl (for word jumping / word selection). For example:
Android uses Key Character Map (.kcm) files to define how physical scancodes map to characters and keycodes. With exkeymo-server, you can write a custom KCM file and generate an installable APK from it. After installing the APK, you go to Settings → System → Languages & input → Physical keyboard and select the exkeymo layout for your keyboard.
The KCM format supports a replace keyword that substitutes one keycode for another. This is the foundation of the navigation layer:
When you press AltGr+J, Android strips the AltGr modifier and emits a clean DPAD_LEFT event. Shift survives the replacement, so Shift+AltGr+J correctly produces Shift+DPAD_LEFT — text selection works as expected.
This is where things get tricky. You might expect ctrl+ralt: replace DPAD_LEFT to produce Ctrl+DPAD_LEFT, but it doesn't. The replace keyword strips all modifiers listed in the behavior's metaState — so both Ctrl and AltGr get removed. The app sees a plain DPAD_LEFT with no Ctrl modifier.
You might also hope that omitting the ctrl+ralt rule and relying on the ralt rule to match would work. It doesn't, because the AOSP matchesMetaState() function enforces exact matching for Ctrl, Alt, and Meta modifiers. If the pressed event has Ctrl bits that the matching rule doesn't account for, the match fails entirely.
Here's the relevant AOSP source (KeyCharacterMap.cpp):
bool KeyCharacterMap::matchesMetaState(int32_t eventMetaState,
int32_t behaviorMetaState) {
if ((eventMetaState & behaviorMetaState) == behaviorMetaState) {
const int32_t EXACT_META_STATES =
AMETA_CTRL_ON | AMETA_CTRL_LEFT_ON | AMETA_CTRL_RIGHT_ON
| AMETA_ALT_ON | AMETA_ALT_LEFT_ON | AMETA_ALT_RIGHT_ON
| AMETA_META_ON | AMETA_META_LEFT_ON | AMETA_META_RIGHT_ON;
int32_t unmatchedMetaState =
eventMetaState & ~behaviorMetaState & EXACT_META_STATES;
// ... cleanup logic ...
return !unmatchedMetaState;
}
return false;
}
And the replace stripping logic:
if (behavior->replacementKeyCode) {
toKeyCode = behavior->replacementKeyCode;
toMetaState = fromMetaState & ~behavior->metaState;
toMetaState = normalizeMetaState(toMetaState);
}
In short: the KCM format simply cannot produce Ctrl+Arrow from any AltGr-based combo. The fallback keyword doesn't help either — the original event gets consumed by the app (doing nothing), so the fallback never fires.
Since KCM files are a dead end for Ctrl combos, the workaround uses two layers:
| Sentinel | Source combo | Key Mapper target |
|---|---|---|
| F13 | Ctrl+AltGr+J | Ctrl+Left |
| F14 | Ctrl+AltGr+L | Ctrl+Right |
| F15 | Ctrl+AltGr+I | Ctrl+Up |
| F16 | Ctrl+AltGr+K | Ctrl+Down |
| F17 | Ctrl+AltGr+H | Ctrl+Home |
| F18 | Ctrl+AltGr+; | Ctrl+End |
| F19 | Ctrl+Shift+AltGr+J | Ctrl+Shift+Left |
| F20 | Ctrl+Shift+AltGr+L | Ctrl+Shift+Right |
| F21 | Ctrl+Shift+AltGr+I | Ctrl+Shift+Up |
| F22 | Ctrl+Shift+AltGr+K | Ctrl+Shift+Down |
| F23 | Ctrl+Shift+AltGr+H | Ctrl+Shift+Home |
| F24 | Ctrl+Shift+AltGr+; | Ctrl+Shift+End |
| PROG_RED | Ctrl+AltGr+O | Ctrl+Backspace |
| PROG_GREEN | Ctrl+AltGr+P | Ctrl+Delete |
type OVERLAY
# CapsLock → Escape
map key 58 ESCAPE
key J {
base: 'j'
shift, capslock: 'J'
ralt: replace DPAD_LEFT
shift+ralt: replace DPAD_LEFT
ctrl+ralt: replace F13
ctrl+shift+ralt: replace F19
}
key L {
base: 'l'
shift, capslock: 'L'
ralt: replace DPAD_RIGHT
shift+ralt: replace DPAD_RIGHT
ctrl+ralt: replace F14
ctrl+shift+ralt: replace F20
}
key I {
base: 'i'
shift, capslock: 'I'
ralt: replace DPAD_UP
shift+ralt: replace DPAD_UP
ctrl+ralt: replace F15
ctrl+shift+ralt: replace F21
}
key K {
base: 'k'
shift, capslock: 'K'
ralt: replace DPAD_DOWN
shift+ralt: replace DPAD_DOWN
ctrl+ralt: replace F16
ctrl+shift+ralt: replace F22
}
key O {
base: 'o'
shift, capslock: 'O'
ralt: replace DEL
shift+ralt: replace DEL
ctrl+ralt: replace PROG_RED
}
key P {
base: 'p'
shift, capslock: 'P'
ralt: replace FORWARD_DEL
shift+ralt: replace FORWARD_DEL
ctrl+ralt: replace PROG_GREEN
}
key H {
base: 'h'
shift, capslock: 'H'
ralt: replace MOVE_HOME
shift+ralt: replace MOVE_HOME
ctrl+ralt: replace F17
ctrl+shift+ralt: replace F23
}
key SEMICOLON {
base: ';'
shift: ':'
ralt: replace MOVE_END
shift+ralt: replace MOVE_END
ctrl+ralt: replace F18
ctrl+shift+ralt: replace F24
}
Save this as a .kcm file and feed it to exkeymo-server to generate an APK. Install the APK, then go to Settings → System → Languages & input → Physical keyboard and select the exkeymo layout for your connected keyboard.
Install Key Mapper and grant it Accessibility Service permissions when prompted.
For each of the 14 sentinel mappings, you need to create a trigger → action pair. Here's the process:
Since keys like F13 or PROG_RED don't physically exist on your keyboard, you might wonder how to record them as triggers. The trick is that the KCM file is already active — so when you press Ctrl+AltGr+J during Key Mapper's "record trigger" mode, the KCM replaces it with F13 before Key Mapper sees it. Key Mapper records F13 as the trigger.
However, Key Mapper will also record all the modifier keys you held down (Ctrl, Shift, AltGr). You need to remove these extra modifier keys from the recorded trigger, leaving only the sentinel key (e.g. just F13). This is important — if you leave the modifiers in, the trigger won't match because replace already stripped them at the KCM level.
For each trigger, create a "Key Event" action with the target keycode and modifiers:
| Trigger (sentinel only) | Action keycode | Action modifiers |
|---|---|---|
| F13 | DPAD_LEFT (21) | Ctrl |
| F14 | DPAD_RIGHT (22) | Ctrl |
| F15 | DPAD_UP (19) | Ctrl |
| F16 | DPAD_DOWN (20) | Ctrl |
| F17 | MOVE_HOME (122) | Ctrl |
| F18 | MOVE_END (123) | Ctrl |
| F19 | DPAD_LEFT (21) | Ctrl + Shift |
| F20 | DPAD_RIGHT (22) | Ctrl + Shift |
| F21 | DPAD_UP (19) | Ctrl + Shift |
| F22 | DPAD_DOWN (20) | Ctrl + Shift |
| F23 | MOVE_HOME (122) | Ctrl + Shift |
| F24 | MOVE_END (123) | Ctrl + Shift |
| PROG_RED (216) | DEL (67) | Ctrl |
| PROG_GREEN (217) | FORWARD_DEL (112) | Ctrl |
The plain AltGr combos and Shift+AltGr combos are handled entirely by the KCM file — no extra app needed. For Ctrl and Ctrl+Shift combos, the KCM converts them to sentinel keycodes (F13–F24, PROG_RED, PROG_GREEN), and Key Mapper translates those sentinels into the final Ctrl+Arrow / Ctrl+Shift+Arrow events.
It's a two-layer workaround for a limitation in Android's KCM replace logic, but once set up, it works reliably and doesn't require root. You get a full Vim-style navigation layer on Android with proper word jumping and text selection support.