Read this before changing the IME (app/src/main/java/com/vocahq/vocaphone/ime/).
Human setup, flavors, and release tags: README.md.
The keyboard is a Compose InputMethodService. Users type on 120 Hz panels.
A frame is 8.3 ms (16.7 ms at 60 Hz). Work that blocks the IME main thread
for a full frame reads as a stutter, not as lag.
A press must dirty one key and send one write to the editor.
- Send letters, shift, layer, return, and delete on pointer down. Space still commits on up so a cursor-swipe is not a space.
- If the gesture becomes a swipe, undo only the seed letter
(
KeyboardReducer.undoLastCharacter). Do not clear the whole composing word. - Accents: commit the letter on down; on long-press up, undo then commit the variant. That keeps the first glyph inside the 8.3 ms budget and still lets a hold replace it.
- Preview is one in-tree overlay (
KeyPreviewLayer) above the key grid. Do not open a ComposePopupper tap. That isWindowManager.addView/removeViewon the down frame (often 5–20 ms) before the letter is sent. - Do not put
Modifier.shadowon idle keys. Pressed state is a local color change on that key. - Swipe trail is a sibling
Canvasover aSnapshotStateList. Never store trail points asvaron the composable that builds the ~40KeyButtons. KeyboardLayouts.rows()allocates a new list. Remember it by(layer, returnKey, leadingPunctuation, numberRow). Rebuilding it on every composing letter prevents Compose skip.- Per-key lambdas must be stable:
remember(key.id) { { currentOnKey.value(key) } }plusrememberUpdatedStateat the parent.{ onKey(key) }inline inKeyboardRowsis a new object every pass and recomposes every key. - Do not read
swipeConsumed.value(or other shared gesture flags) duringKeyButtoncomposition. Gestures may read theMutableStateon up.
Do not rewrite the IME in Views unless traces still show Compose pointer and skip missing the frame after measurement.
Known leftover: each special key still has its own pointerInput. Do not
add more. A single parent pointer stream is the next cut if Choreographer
p95 climbs again.
getTextBeforeCursor / getTextAfterCursor / getCursorCapsMode are
blocking Binder calls into the app being typed into. Measured 7–16 ms per
read on a POCO F1 typing into Messages — a whole 60 Hz frame, spent on the
other app’s main thread.
- Composing text lives in
KeyboardState.composing. The strip uses that, not a fresh editor read, while a word is in progress. handleCommandskipsscheduleEditorTextRefresh()forSetComposingText. Other commands coalesce the read at 50 ms (EDITOR_TEXT_REFRESH_DELAY_MS).- Do not call
finishComposingTextwhencomposingRegionActiveis already false. That is still an IPC. - Batch multi-call edits (
finishComposing+commitText, double-space period) withbeginBatchEdit/endBatchEdit. - Keep
setComposingTextfor letters so a suggestion can replacehelwithhello. Do not switch letters tocommitTextwithout measuring Messages and Chrome on a phone: composing is what makes the replacement cheap. - Caching text-before-cursor and expected selection locally is the next IPC cut; the 50 ms coalesced read is the reconcile, not the source of truth.
- Load
en.txt, bigrams, the emoji catalog, andemoji/suggestions.tsvonDispatchers.DefaultinonCreate, never inside the first composition. The strip and emoji panel already render empty. strip()/swipe()/similar()run off the composition thread (LaunchedEffect+Dispatchers.Default). Do not move them back intoremember.LaunchedEffectcancellation is cooperative. Tight 10k-word loops must takeshouldAbort(checked every 128 words). Otherwise a cancelled job still burns CPU and fights the 120 Hz thread for cores.- Do not key the strip effect on
editorTextwhilecomposingis nonempty. That scheduled a second scan ~50 ms after every letter. - Swipe scoring of a messy path is 3.5 ms median / 5.5 ms p95 on a
Nothing Phone (1). That is most of a 120 Hz frame. It stays off the pointer
thread. Prefer
Channel.UNLIMITEDwith ordered apply overCONFLATEDso two swipes in a row still commit in order.
Word list, bigrams, emoji catalog: assets/keyboard/ at the repo root, merged
into the APK via sourceSets in app/build.gradle.kts. iOS reads the same
files. Edits belong there, not in a platform-local copy.
DictationState.level updates at 10 Hz while recording. Collecting it at the
IME root recomposes the whole keyboard, including keys. Isolate it in
DictationBar / MicButton if you touch that path. The waveform
rememberInfiniteTransition must only exist while isRecording.
Activity-level frame metrics never see IME presses (different window). Time the work a press actually does, on a 120 Hz phone:
# Host JVM
./gradlew :app:testFullDebugUnitTest -i \
--tests 'com.vocahq.vocaphone.ime.KeyboardHotPathTest' \
-Dvocaphone.benchmark=1
# Phone
./gradlew :app:connectedFullDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.keyboardBenchmark=truejust keyboard-benchmark runs the host pass and the device pass if a phone is
attached. Logcat tag: VocaPhoneBenchmark. Opt-in, not a CI gate — phones
differ. A 120 Hz budget is 8333 µs; report median and p95.
On 2026-08-22, Nothing Phone (1) (A063, 120 Hz): strip("hel") 579 µs
median, swipe("tjhinl") 3.5 ms median, reducer press 6.5 µs, Choreographer
frame gap 8.35 ms median (one vsync). Treat those as a baseline, not a
pass/fail.
- JDK 21 exactly (F-Droid rebuilds on 21).
fullflavor for daily work; every Gradle task name includes the flavor (assembleFullDebug,testFullDebugUnitTest).fdroidflavor has no prebuilt sherpa-onnx. just build/just run/just cifromandroid/.- WSL: USB debugging often shows up only on Windows
adb.exe, not/usr/bin/adb. Ifadb devicesis empty, try/mnt/c/Users/<you>/platform-tools/adb.exe devices -l. Copy APKs to a Windows path (/mnt/c/Users/.../Temp/) beforeadb.exe install;wslpath -wcan mangle. - Pixel is the baseline device in the README. 120 Hz work should also be felt on a 120 Hz panel (Nothing Phone 1 was used for the hot-path bench).
| File | Role |
|---|---|
ime/VocaPhoneKeyboard.kt |
Compose keyboard, gestures, preview, trail |
ime/VocaPhoneInputMethodService.kt |
InputConnection, coalesced editor reads |
ime/KeyboardModel.kt |
KeyboardReducer, composing, swipe undo |
ime/SuggestionEngine.kt |
Dictionary, strip, swipe, shouldAbort |
ime/LifecycleInputMethodService.kt |
ComposeView inside InputMethodService |