V7 is a Vietnamese input method for Android, designed for an external QWERTY or steno keyboard. It combines deterministic single-syllable entry, predictive two-syllable V7 islands, Telex, and bundled Stripped Plover. Inference runs locally through Rust and KenLM.
The WebUI serves Android IME and its settings only. The separate 60-second practice interface and its Android wrapper remain supported. This is the project's single README; focused reference documents remain linked below.
- Android architecture, settings, IME build, and releases
- Keyboard layout, single-syllable rules, two-syllable rules, and usage
- Clipboard slots
- Training, headless inference, and V7 code format
- Practice game and Android bundle
- Development and validation
| Path | Purpose |
|---|---|
ime-android/ |
Android IME, native bridges, settings, JNI, and bundled Plover integration. |
src/ime.ts, src/imeEngine.ts |
Composition display/settings controller and DOM-free input engine. |
src/editorCore.ts, src/textBuffer.ts, src/undoManager.ts |
Editor rendering, island buffer, and undo logic. |
static/ime.html, static/ime.css, static/dictionary.html, static/plover-dictionary.css |
APK-only composition and settings UI assets. |
static/script.js |
Generated IME bundle; built by Vite and excluded from Git. |
inference-rs/ |
Shared Rust/KenLM inference engine, Android JNI library, CLI, and headless HTTP API. |
static/practice.html, practice-android/ |
Independent 60-second practice game and its Android app. |
tests/, scripts/ |
Unit tests, Android WebView bridge checks, practice checks, and build helpers. |
preprocess_corpus.py, preprocess_corpus.cpp, train_lm.sh |
Corpus preprocessing and language-model training. |
Dockerfile, Dockerfile.practice-android, docker-compose.yml |
Headless inference, training, and practice builds. |
lm.binary |
Generated language model, selected by the user on Android; not bundled or tracked. |
ime-android packages the V7 WebUI as an Android input method with application ID
com.huynhtrankhanh.v7ime.
V7 and Plover composition share ten persistent clipboard slots. Alt+0–9
copies selected host text or the visible IME buffer; Ctrl+0–9 appends exact
fixed text with * undo. Plover paste finishes its current preedit and starts a
fresh translation context after the pasted text. Only occupied slots appear as
compact paste buttons; empty slots consume no screen space. Edit or clear slots
in Settings → Manage clipboard slots. Storage belongs to the native Android
app and survives WebView resets. See clipboard behavior.
The IME requires Android 8.0 (API 26) or newer. API 26 is the minimum supported by AndroidX JavaScriptEngine, which owns durable out-of-process dictionary imports.
- Android uses the dedicated
static/ime.htmlandstatic/ime.cssinterface, with display/settings behavior compiled intostatic/script.jsand input behavior bundled intoime-sandbox.js. These assets exist solely for Android IME and its settings dictionary manager. V7ImeServicehosts that UI in aWebView. The WebUI detectswindow.AndroidIme, enables stripped display mode, and renders snapshots of native-owned composition state.- V7/Plover hardware events go directly to a DOM-free AndroidX
JavaScriptSandboxisolate. Chord capture, V7 decoding, inference results, candidates, undo, raw outlines, and clipboard composition live there. Native V7 key handlers wait for sandbox execution and local inference, then apply composing text directly before returning. The display WebView receives snapshots and does not acknowledge hardware events or write V7 composing text. Events received during sandbox startup are queued with their editor generation. - Stripped Plover translation still uses its separate asynchronous engine runtime. Its continuations and later strokes are serialized inside the input isolate, preserving stroke order and captured Caps Lock state while replies are pending. Native code yields to Android's main loop for that runtime.
- Telex hardware input is handled natively; its pure linguistic conversion runs synchronously in a separate sandbox isolate. The IME does not render an on-screen key layout.
- V7 input, Telex, and dictionary importing share the application's single sandbox using separate isolates. Telex loads and warms its tone oracle in the background; its banner explicitly reports Latin fallback until conversion is ready.
- Inference requests go through JNI to the bundled
inference-rsand KenLM code. No inference request leaves the device. - Settings can replace the bundled two-syllable lexical dictionary with a
persistently selected UTF-8
.txtdocument. Each LF- or CRLF-delimited line must contain exactly two words; malformed and non-V7 entries are ignored. The optional file is opened only for dictionary-mode requests, so an unavailable provider cannot disable compositional inference. Provider modification metadata is checked in the background at most once per second during dictionary-mode typing. Edits become visible on a subsequent stroke after the refresh completes; reselection invalidates the cache immediately. The first use loads the dictionary, while unchanged dictionaries are neither reread nor copied into Rust on subsequent strokes. - The language model is not bundled. Android retains a Storage Access Framework document grant and passes its seekable file descriptor directly to KenLM, which memory-maps it without copying the model into app-private storage. The descriptor is opened only when loading or switching models; subsequent inference requests reuse the native engine without contacting the provider.
- Stripped Plover is bundled as a local browser runtime in a process-wide, non-visual WebView separate from both the IME interface and dictionary manager. Its persistence bridge uses Android's private native SQLite database; no Node runtime or external server is required.
- Stripped Plover's host-command events use native Android surfaces:
{PLOVER:LOOKUP}opens an entry lookup whose rows name their source dictionary,{PLOVER:ADD_TRANSLATION}opens an add form with a writable dictionary picker, and{PLOVER:CONFIGURE}opens V7 IME settings directly. - The lookup stroke and add-translation outline fields mark their editor
context for raw outline capture. V7 then joins physical steno chords with
/and collapses to a labeled 48 dp Raw outline mode bar. A lone*chord removes the latest slash-delimited stroke from the textbox. Translation and lookup-text fields use standard V7 and preserve the user's current STENO/Normal selection, including the Ctrl+Shift toggle. - Native command dialogs request the IME surface as soon as their first editor is focused, retain it during keyboard navigation, and do not close from an accidental outside touch.
- Add-translation uses one-tap, 48 dp dictionary choices; dialog navigation keys return to the native activity while those controls have focus, without consuming V7's Ctrl+Shift toggle or changing its saved mode. Outline Enter advances to translation, and translation Enter submits.
- Moving the cursor or changing editors finishes the active composition and clears the WebUI buffer, so already-entered text remains in the editor while a new composing session starts cleanly.
- On Android 12 and later, the two candidate-difference regions are attached to
composing text as grammar
SuggestionSpans with their alternative phrases. - Physical Enter is handled by the service rather than the WebUI: it invokes an editor-provided custom or standard action when present, otherwise it forwards the original Enter key events to the editor.
- The physical Ctrl+Shift chord switches between STENO capture and ordinary hardware-keyboard typing. The chord toggles once per press cycle while solo Ctrl and Shift retain their ordinary behavior.
Ctrl+Tabswitches between V7/Stripped Plover and Telex composition. From Normal typing it enters Telex directly;Ctrl+Shiftfrom Telex enters Normal typing, whileCtrl+Shiftfrom Normal typing returns to V7/Plover. Telex keeps the current word as Android PREEDIT, supports ordinary Latin text via repeated-mark escapes, and commits/ends PREEDIT at whitespace or symbols. Empty-PREEDIT Backspace passes through to the editor, and Enter retains the editor's standard Android action behavior after finalizing PREEDIT. Numpad and layout-specific printable keys also terminate PREEDIT rather than bypassing the Telex composer.- Normal typing uses a labeled 48 dp status bar, matching the compact active Stripped Plover treatment instead of leaving the composition UI visible. Compact transitions apply their height immediately, and returning to V7 restores the last measured full height before the first expanded paint.
- The IME HTML starts with the same empty composition markup used after JavaScript initialization and applies native compact-mode classes before its layout is parsed, preventing placeholder and full-height layout flicker.
- While STENO capture is active, the physical Q+A chord opens Android's input
method picker. The
[key commits the current PREEDIT and starts a clean composing session; it does not delete the committed text. The apostrophe key commits the current PREEDIT and inserts one space. - Every physical event carries Android's current Caps Lock state into the WebUI. While that state is on, all cased characters produced anywhere in the steno pipeline are uppercase, including direct and inferred V7, Emily, Stripped Plover, candidate previews, piecemeal replacements, and selected candidate output. Choosing a candidate preserves the casing assigned when its output was produced; Caps Lock at selection time does not retroactively uppercase older candidate or fixed text.
- Connecting or disconnecting a physical keyboard restores the V7 input view when an editor is still active. A generation guard prevents a delayed restore from showing the IME over an editor that has already closed or changed.
See Android hardware-keyboard interactions for the complete mode table, PREEDIT semantics, and native/WebUI event-routing order.
See Telex behavior for the three-mode state machine and the supplied Telex adapter supplement for the converter's detailed key and tone-placement rules.
See Virtual-keyboard visibility with an external keyboard for the attach/detach recovery policy, lifecycle safeguards, and verification matrix.
Open V7 IME from the launcher or tap its settings entry in Android's keyboard settings. The native settings activity includes:
- a local
lm.binarydocument selected with Android's Storage Access Framework; - a full-screen Stripped Plover dictionary manager opened from settings, using the dedicated Android settings WebView without an Activity action bar or nesting editable fields inside the IME;
- durable background dictionary imports in AndroidX's out-of-process JavaScript sandbox, with entry counts, phases, and a determinate loading notification while native SQLite installs the source transactionally;
- export and import controls for the complete app-private Stripped Plover SQLite database;
- an option to save the APK's complete Corresponding Source as
v7-ime-source.zip; - shortcuts to enable V7 IME and open the input-method picker.
See Stripped Plover dictionary management on Android for the shared WebUI architecture, the deliberately narrow native bridge, and the import/export file flow.
See App-data import and export for the database file format, replacement safeguards, and the data that is intentionally not part of an export.
See Bundled Stripped Plover runtime for the pinned external-source build, separate engine WebView, background JavaScript sandbox, typechecked Node compatibility surface, native SQLite implementation, and artifact licensing boundary.
The bundled Android distribution, including the APK and its Stripped Plover
runtime, is conveyed as a combined work under GPL-3.0-or-later. The APK bundles
its complete Corresponding Source as v7-ime-source.zip; Settings can save
that exact asset without network access. The archive contains this repository
plus the exact pinned KenLM and Stripped Plover checkouts, and intentionally
excludes user language models.
Original V7 source files remain separately available under 0BSD. The distribution-level GPL notice does not replace their 0BSD license, transfer their copyright, or replace the respective notices on Stripped Plover, KenLM, and other third-party components.
The IME is a compact companion for an external steno keyboard. It keeps the reduced composing buffer and fitted alternatives visible without drawing an on-screen key layout. Its height follows the content: the empty and short-text states stay compact, while longer text and alternatives receive more room. Occupied clipboard slots appear as paste buttons below the toolbar. The empty composing buffer shows “Ready when you are” even when saved slots are available.
Each alternative uses only the width its summary needs. Alternatives pack beside one another and wrap onto another row only when the remaining width is insufficient. Android expands the IME to fit those rows; the candidate area becomes vertically scrollable only after the safe screen-height cap is reached.
Piecemeal mode keeps natural spaces between editable syllables. Its highlight does not shift the surrounding text, including when the active target is in the middle of the phrase:
While Stripped Plover is active, the composition interface collapses to a 48 dp status bar with the keyboard switch button. Occupied clipboard slots remain accessible in an additional row below it:
Regenerate these screenshots with npm run docs:screenshots. This builds the
current WebUI, runs the Android bridge checks, and captures the four states in
Chromium at 412 CSS pixels wide using deterministic inference and clipboard
fixtures.
The Android build uses JDK 21. It invokes the root WebUI build, fetches and browser-bundles the
pinned Stripped Plover revision, compiles Rust/KenLM for Android, and creates
the source ZIP asset. Install the root JavaScript dependencies, Rust 1.88,
cargo-ndk, JDK 21, Android NDK 27.2.12479018, and Gradle:
npm ci
rustup target add \
aarch64-linux-android armv7-linux-androideabi \
x86_64-linux-android i686-linux-android
cargo install cargo-ndk --version 4.1.2 --locked
ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018" \
gradle -p ime-android assembleDebugThe APK is written to:
ime-android/app/build/outputs/apk/debug/app-debug.apk
The manual Generate V7 IME Release App Bundle GitHub Actions workflow builds
a signed release App Bundle (.aab) from a version name, an optional Android
versionCode, and a signing password. It uploads the App Bundle, its SHA-256
checksum, matching bundle/key certificate fingerprints, and a compressed
artifact bundle.
The same flow is available locally after installing the Android, Node, Rust, and NDK dependencies above:
V7_SIGNING_PASSWORD='your signing password' \
ime-android/scripts/build-release-aab.sh "1.0.0" 100Artifacts are written to android-artifacts/ by default. The release script
derives a deterministic PKCS#12 key from the password when no upload key is
provided. For Play uploads, provide the registered key through
SIGNING_STORE_FILE, SIGNING_STORE_PASSWORD, SIGNING_KEY_ALIAS, and
SIGNING_KEY_PASSWORD (plus SIGNING_STORE_TYPE when it is not PKCS#12); the
script verifies the generated App Bundle with
jarsigner, records the bundle and key SHA-1/SHA-256 certificate fingerprints,
and fails if they differ.
Keep the same derived signing password for compatible updates, or use the
registered Play upload key. The private keystore is temporary build material
and is never copied into android-artifacts/.
The frontend organizes text into "islands" to manage spacing intelligently. The types are:
- Vietnamese (
vietnamese): Literal syllable text. - V7 (
v7): Predictive codes with compositional or dictionary inference. - Plover (
plover): Literal output with a committed or preedit phase. - Punctuation:
.,!?. - Capital Letter: Literal uppercase letters.
- Spacing: Explicit Space or Newline.
- Emily: Symbol output with explicit attachment spacing.
- Fixed clipboard text: Literal slot output with exact whitespace at both boundaries.
Spacing Rules:
- Vietnamese ↔ Vietnamese: Space added.
- Vietnamese → Capital: Space added (e.g.,
Xin Chào). - Punctuation → Vietnamese: Space added (e.g.,
. Xin). - Punctuation → Capital: Space added (e.g.,
. A). - Capital → Capital: No space (e.g.,
USA). - Capital → Vietnamese: Space added.
- Punctuation → Punctuation: No space.
- Any ↔ Spacing: No extra space added.
- Emily Symbols: Explicit attachment metadata controls whether spacing is added around the symbol.
Island in src/textBuffer.ts is a discriminated union: narrow island.type
before accessing variant-specific fields. Construct islands with
createIsland(type, value, options); its options and return type follow the
chosen type. The complete argument tuple must match one island variant; narrow
a union-valued type before calling the constructor with variant-specific options.
V7 islands require one capitalization choice (none, initial,
or upper) and one validation state (pending, valid, or invalid). The
factory defaults to compositional mode, no capitalization, and pending
validation. Only dictionary mode exposes dictionaryBucketSize; starting a
new inference request discards the previous result metadata. Plover islands
carry phase: "committed" | "preedit" and default to committed.
An optional spacing: { before, after } overrides automatic boundary spacing.
The right island's before takes precedence over the left island's after;
explicit space/newline islands always suppress extra spacing. Vietnamese, V7,
and Plover islands share the normal syllable spacing and piecemeal rules.
Clipboard islands default to { before: false, after: false } and remain
literal, including their whitespace. Undo retains the complete island variant
and its metadata. Candidate selection replaces each V7 island with its chosen
Vietnamese text and retains that island's spacing overrides. Other islands keep
their types, values, and metadata. Both replacement-only and alternating
fixed/V7 candidate responses follow this rule; fixed response chunks already
contain rendered boundary spaces and must not be inserted again. The Android
inference wire format remains version 2 with
kind: "fixed" and kind: "v7"; frontend variants are converted at that boundary.
The IME uses a QWERTY-to-Steno mapping:
| QWERTY | Steno | QWERTY | Steno |
|---|---|---|---|
Q |
# |
U |
-F |
A |
S- |
J |
-R |
W |
T- |
I |
-P |
S |
K- |
K |
-B |
E |
P- |
O |
-L |
D |
W- |
L |
-G |
R |
H- |
P |
-T |
F |
R- |
; |
-S |
C |
A |
T, G |
-D |
V |
O |
Y, H |
-Z |
N |
E |
Space |
* |
M |
U |
S-P: Inserts a Space Island.- Physical
Enterfinishes composition and invokes the Android editor action, or forwards Enter when no action is supplied.
Shift + [Letter]: Appends the uppercase letter literally as a Capital Island. Spacing is determined by the spacing rules (e.g. no space if previous was capital).Caps Lock: While the keyboard's current Caps Lock state is on, every cased character produced by the steno pipeline is uppercase—not only inferred V7 islands. The state captured with each physical key event applies to direct syllables, Emily output, Stripped Plover output, previews, alternatives, and piecemeal edits. Choosing a candidate preserves the casing already attached to its output; the Caps Lock state of the selection action never retroactively uppercases older candidate or fixed text.
Standard steno chords for punctuation:
TP-PL: Period (.)KW-BG: Comma (,)KW-PL: Question mark (?)TP-BG: Exclamation mark (!)
Punctuation marks are inserted as Punctuation Islands without trailing spaces. Spacing after punctuation is handled automatically by the spacing rules.
Emily symbol strokes use the WH starter. The capitalization command is left-hand WHR rather than WH*, avoiding a collision with two-syllable V7 mode; the right-hand WH-R chord remains the period symbol. Attachment keys control spacing around the symbol in the space attachment method:
- No attachment keys: no surrounding spaces (symbol attaches to both sides).
A: insert space before the symbol.O: insert space after the symbol.AO: insert spaces on both sides.
Spacing is not applied for {*!} and {*?} retrospective space macros.
The composition view shows the nine rightmost Vietnamese syllables with piecemeal numbers and highlights. Intervening non-Vietnamese runs of up to three characters remain visible; longer runs become …. An empty buffer shows 👋. The candidate panel is hidden without alternatives and omits candidate 1 (current), which is already shown in the buffer. Red and black diff regions identify the two useful change sections; their ranges are also logged for debugging.
There is no standalone editor, raw-text textarea, browser clipboard override, keyboard-debug display, or setStrippedDisplay embedding API. Q+A (#S) opens Android's input-method picker. Ctrl+C and other ordinary editor shortcuts retain their Android host behavior. Clipboard slots are app-private: Alt+0–9 copies selected host text or the IME composition, Ctrl+0–9 appends exact fixed text, and normal * undo removes each paste. See clipboard slots for selection, persistence, clearing, and native-routing details.
Press the # stroke (physical Q) to toggle Stripped Plover. While enabled, all strokes go to the bundled runtime and the IME collapses to its 48 dp status bar. While disabled, strokes that do not match V7's built-in rules may be translated as one-shot Plover strokes.
Manage dictionaries from Android settings: refresh, reorder, enable/disable, solo/end solo, export, rename, and delete writable dictionaries; import JSON or Python dictionaries and optionally merge JSON; search/edit entries by stroke or translation; and perform stroke or reverse lookup. The dictionary manager runs in a separate settings activity with AndroidDictionary, while composition uses AndroidIme. Both call the app-local runtime rather than a server.
This section is the complete rule set for single-syllable mode (i.e., strokes entered without * held). It is intentionally exhaustive so you can determine exactly what every valid stroke will output.
For single-syllable mode, each stroke is parsed in this exact order:
- Optional capitalization marker:
# - Optional on-glide marker:
S - Initial consonant (longest match, 4 keys -> 1 key)
- Vowel nucleus (longest match, 4 keys -> 1 key) (required)
- Final consonant (longest match, 2 keys -> 1 key, optional)
- Tone (all remaining keys, optional)
If any stage fails (especially vowel or leftover tone parsing), the stroke is ignored (no text output, no state mutation).
| Steno Keys | Sound | Steno Keys | Sound |
|---|---|---|---|
PW |
b | TPH |
n |
K |
c | TPR |
nh |
KH |
ch | TPW |
ng/ngh |
KWR |
d | P |
p |
TK |
đ | R |
r |
TP |
ph | KP |
s |
TKPW |
g/gh | T |
t |
H |
h | TH |
th |
KWH |
gi | TR |
tr |
KHR |
kh | W |
v |
HR |
l | WR |
x |
PH |
m |
Additional deterministic onset orthography:
- Define vowel classes used below:
- Back-vowel group:
a, ă, â, o, ô, ơ, u, ư, ua/uô, ưa/ươ - Front-vowel group:
e, ê, i, iê/ia, y
- Back-vowel group:
TPW(ng/ngh) outputs:ngwhen on-glide is present OR vowel is in the back-vowel groupnghotherwise (front-vowel group)
TKPW(g) outputs:gwhen on-glide is present OR vowel is in the back-vowel groupghotherwise
KWH(gi) outputs:gwhen no on-glide and vowel isioriê/iagiin all other cases
K(c) outputs:qwhen on-glide is presentcwhen no on-glide and vowel is in the back-vowel groupkwhen no on-glide and vowel is in the front-vowel group
| Steno Keys | Sound | Steno Keys | Sound |
|---|---|---|---|
A |
a | OEU |
iê/ia |
AE |
ă | AEU |
ua/uô |
AO |
â | AOE |
ưa/ươ |
E |
e | AOU |
ư |
AU |
ê | OU |
ơ |
EU |
i | OE |
ô |
O |
o | AOEU |
y |
U |
u |
| Steno Keys | Sound | Steno Keys | Sound |
|---|---|---|---|
FP |
j (i/y) | RP |
nh |
F |
w (u/o) | P |
m |
R |
n | FR |
ng |
Final rendering rules:
F(w) renders as:uif vowel is one of:iê/ia, ư, ưa/ươ, ê, u, ă, â, iootherwise
FP(j) renders as:yif vowel isăorâiotherwise
P,R,FR,RPusually render asm,n,ng,nh, except under stop-tone conversion (below).
Tones are determined by the remaining keys after matching the final consonant.
| Steno Keys | Tone | Diacritic | Example |
|---|---|---|---|
| (None) | Ngang | (none) | ma |
L |
Sắc | Acute (´) | má |
G |
Huyền | Grave (`) | mà |
B |
Hỏi | Hook (?) | mả |
LG |
Ngã | Tilde (~) | mã |
BG |
Nặng | Dot (.) | mạ |
BL |
Sắc (Stop) | Acute (´) | mát |
BLG |
Nặng (Stop) | Dot (.) | mạt |
Stop-tone conversion (BL / BLG) is strict:
- Allowed only when steno coda is one of:
P-> output codapR-> output codatFR-> output codacRP-> output codach
BLforces tone tosắc;BLGforces tone tonặng.- This reflects Vietnamese checked-syllable orthography where stop codas are written
-p/-t/-c/-chrather than the nasal outputs ofP/R/FR/RP. - If
BL/BLGappears with no coda or with codasF/FP, the stroke is invalid and ignored.
On-glide is only read from the leading S position (before onset parsing). It modifies nucleus construction as follows:
- General rule:
- open syllable (no coda): prefix
obefore the nucleus - closed syllable (has coda): prefix
oand attach tone mark to the main vowel letter
- open syllable (no coda): prefix
- Specialized behavior by vowel:
iê/ia:- no coda ->
uy+ accenteda - has coda ->
uy+ accentedê
- no coda ->
i:- has coda ->
u+ accentedy - no coda:
- if onset is
c->u+ accentedy - otherwise -> accented
u+y
- if onset is
- has coda ->
ăwith rendered codaw/j(from steno codasF/FP): replaces base with accenteda; prefix is:uif onset iscootherwise
âorê: usesu+ accented vowel (notoprefix form)- any vowel with onset
c: usesu+ accented vowel (reflectsqu...behavior)
These are applied after parse and before final coda rendering:
iê/ia:- no coda ->
iaform (with proper tone placement) - has coda ->
iê/yêform (tone onê) - onset empty + no on-glide + has coda uses
yê...
- no coda ->
ua/uô:- no coda ->
ua(tone onu) - has coda ->
uô(tone onô)
- no coda ->
ưa/ươ:- no coda ->
ưa(tone onư) - has coda ->
ươ(tone onơ)
- no coda ->
ă+ (worjcoda): nucleus written witha-tone series (orthographicau/ay-type behavior).
# uppercases the first output character of the assembled syllable.
A single-syllable stroke is ignored if any of these occurs:
- No valid vowel can be matched after optional
#, optional leadingS, and onset parsing. - After optional coda parsing, remaining keys are neither empty nor a valid tone key sequence.
BL/BLGis used without a compatible coda (P,R,FR,RP).- Any extra unmatched key remains at the end of parse.
To enter a syllable with certainty:
- Decide whether syllable-initial capitalization is needed (
#or not). - Decide whether you need on-glide (
Sbefore onset) foro/umedial behavior (qua,hoa,uy...patterns). - Enter onset keys using the longest onset pattern from the table.
- Enter exactly one vowel pattern (longest match principle).
- Optionally enter one coda pattern.
- Optionally enter tone keys:
- plain tones:
L,G,B,LG,BG - stop tones only with stop codas:
BLorBLG
- plain tones:
- Ensure no extra keys remain; otherwise the stroke will be ignored.
- The parser is greedy for onset and vowel; if two options share prefixes, the longest valid one wins.
- On-glide is only the leading
S, not any laterSthat belongs to another pattern. - Single-syllable mode is strictly orthographic assembly; there is no language-model inference in this path.
- If a stroke is ignored, nothing is partially committed.
A V7 Island allows encoding two syllables in a single stroke by using the keyboard as two separate halves. Ordinary, compositional V7 uses * (Spacebar). Dictionary mode carries the same two-syllable code but first restricts inference to lexical dictionary pairs: flip both D and Z on the ordinary chord. When both vowels are e or u, use the symmetric starless forms * → -DZ, *Z → EDZ, *D → ODZ, and *DZ → OEDZ. Redundant D with left A/O and Z with right E/U are therefore dictionary forms, not ordinary aliases.
If the selected dictionary has no entry for a dictionary-mode code, the engine falls back to the full compositional candidates and ranks them with KenLM using zero preceding context. Thus a missing lexical entry does not produce a dictionary miss. Before inference completes, the IME displays ordinary codes as [code1code2] and dictionary codes as [D: code1code2]. If either constituent code has no inference candidates, the corresponding illegal form is [I: code1code2] or [DI: code1code2] in dictionary mode.
Structure: [Left Syllable]*[Right Syllable]
The V7 layout is designed to be perfectly mirrored between the left and right hands.
- Left Hand: Uses the standard QWERTY keys
Q, W, E, R, Afor consonants,S, D, Ffor tones, andC, Vfor vowels. - Right Hand: Mirrors the left hand using
P, O, I, U, ;for consonants,L, K, Jfor tones, andN, Mfor vowels.
The following patterns show which keys to press for each consonant. The dots represent the relative positions of the 5 consonant keys for each hand.
Tones use 3 bits (3 keys), while vowels use 2 bits (2 keys).
| Consonant | Left Hand Keys | Right Hand Keys |
|---|---|---|
| 0 | (None) |
(None) |
| b | # + S + P |
-T + -S + -P |
| ch | S + T + H |
-S + -L + -F |
| d | # + T + P + H |
-T + -L + -P + -F |
| dd | # + S + T |
-T + -S + -L |
| g | # + S + T + P |
-T + -S + -L + -P |
| h | H |
-F |
| k | # + T |
-T + -L |
| kh | # + S + T + H |
-T + -S + -L + -F |
| l | # + S + H |
-T + -S + -F |
| m | P + H |
-P + -F |
| n | T + P + H |
-L + -P + -F |
| ng | # + T + P |
-T + -L + -P |
| nh | # + S + T + P + H |
-T + -S + -L + -P + -F |
| p | P |
-P |
| ph | T + P |
-L + -P |
| r | # + H |
-T + -F |
| s | S + T + P |
-S + -L + -P |
| t | T |
-L |
| th | T + H |
-L + -F |
| tr | # + T + H |
-T + -L + -F |
| v | # + P |
-T + -P |
| w | # + S |
-T + -S |
| x | # + P + H |
-T + -P + -F |
| z | S + T + P + H |
-S + -L + -P + -F |
| Vowel | Left Hand Keys | Right Hand Keys | Notes |
|---|---|---|---|
| a | A |
U |
|
| o | O |
E |
|
| i | A + O |
U + E |
|
| e/u | (None) |
(None) |
Default e. Becomes u if -D (Left) or -Z (Right) is pressed. |
| Tone | Left Hand Keys | Right Hand Keys |
|---|---|---|
| Ngang (0) | (None) |
(None) |
| Sắc (1) | K |
-G |
| Huyền (2) | W |
-B |
| Ngã (4) | K + W |
-G + -B |
| Hỏi (3) | R |
-R |
| Sắc (Stop) (6) | K + R |
-G + -R |
| Nặng (5) | W + R |
-B + -R |
| Nặng (Stop) (7) | K + W + R |
-G + -B + -R |
CRITICAL: MODE SWITCHING
Ordinary strokes distinguish these input modes using the * (Spacebar) key:
- Single Syllable Mode: Type your stroke WITHOUT holding down the
*key. - V7 Island Mode (Two Syllables): Type your stroke WHILE HOLDING DOWN the
*key.
Dictionary-mode two-syllable strokes also use the starless center forms described above; those are recognized as predictive islands before single-syllable decoding.
Most common Vietnamese syllables can be fully specified using standard steno chords. These are immediately converted to text and added to the sentence.
- Action: Press the keys for a single syllable.
- Result: Unambiguous text is inserted immediately.
A V7 island represents two syllables partially specified. This mode leverages the V7 inference engine to predict the best candidates based on the surrounding context.
- Action: Hold down the
*key (Spacebar) while pressing the keys for the two syllables. - Result: The IME fits up to four alternatives beneath the current top candidate. The main text buffer shows the inferred text from the top candidate. Raw V7 blocks are shown only when inference is unavailable.
- Candidate diffing: When candidates differ, the UI optimizes over the whole rendered buffer and highlights the smallest useful set of ordered change sections. It may show no boxes when visible candidates are identical, one red region box for a single change region, or red then black region boxes for two separate change regions. A region box can contain multiple syllables; it is not drawn once per syllable.
When candidates are displayed, select one to fix that interpretation, or keep composing to update the predictions. Use the following keys (standard steno chords):
-T: Candidate 1-TS: Candidate 2-S: Candidate 3-D: Candidate 4-Z: Candidate 5
Selecting a candidate fixes each ambiguous V7 island as Vietnamese text while preserving the surrounding islands and spacing directives. This also applies to automatic selection before punctuation or Enter and combined selection and syllable strokes.
Flattening the whole buffer used to preserve its immediate text but could change spacing on the next stroke or destroy boundaries needed by later edits:
| Buffer ending at selection | Subsequent input | Required behavior |
|---|---|---|
Emily symbol with after: false, such as attached ( |
Vietnamese syllable | Keep it attached: chào(xin |
Emily symbol with after: true, such as spaced + |
Punctuation | Keep the requested space: chào + ! |
| Vietnamese, V7, or Plover text with a spacing override (including Emily retroactive commands) | Text or punctuation without its own override | Honor the retained after directive |
| Literal clipboard text | Text | Preserve literal whitespace and its boundary policy |
| Explicit space or newline, or an empty non-V7 island | Vietnamese syllable | Suppress extra automatic spacing |
| Capital letter | Another capital letter | Continue the run: chào AB |
Interior boundary spaces were already present in the flattened text, so their
loss often became visible only after further input. Keeping the islands also
preserves those boundaries for piecemeal replacement and retroactive spacing;
undo restores the original unresolved islands. As usual, a new island's explicit
before directive takes precedence over the preceding island's after, and
explicit space/newline islands suppress extra automatic spacing.
The candidate list mirrors the buffer boxes instead of repeating the entire sentence. Each row shows current when it matches the top preview, or shows only the section values that would change: red for the left section, black for the right section. Short section summaries are laid out compactly so more candidates fit on screen.
Candidate selection keys can also be combined with single-syllable strokes in the same chord: the candidate is selected first, then the syllable is appended, and this combined action is treated as a single undo step.
When no candidates are active, a valid single-syllable stroke plus the first-candidate suffix -T falls back to the syllable alone. For example, KAOT outputs the syllable from KAO instead of being ignored.
If the combined chord does not form a valid single-syllable stroke, it is ignored.
The text buffer always marks the nine rightmost Vietnamese syllables, including inferred syllables from unresolved V7 islands while candidates are displayed. Syllable numbers count from right to left: T- targets syllable 1, the rightmost eligible syllable; P- targets syllable 2, one syllable to the left; then H-, TK-, PW-, HR-, K-, W-, and R- target syllables 3 through 9.
The selected syllable is shown without its number. Type a valid one-syllable Vietnamese stroke to replace it; even though numbers count from right to left, the cursor advances forward in text order to the next marked syllable on the right, and exits after replacing the rightmost marked syllable. Any invalid stroke or non-syllable stroke exits piecemeal edit mode and is then handled normally. Candidate-selection chords are never combined with piecemeal replacement; when a chord selects a candidate, piecemeal edit exits and the chord is handled by the normal candidate-selection path. A lone selection stroke such as -T exits piecemeal and selects candidate 1 when candidates exist; if no candidates exist, it exits piecemeal and does nothing else. A combined first-candidate stroke such as KAOT selects candidate 1 and appends KAO when candidates exist; if no candidates exist, it still exits piecemeal and appends KAO as a normal single syllable.
Fixed text syllables are validated against the generated Vietnamese syllable set. Editing a syllable inside a V7 island splits that island around the replacement and inserts the new syllable as fixed Vietnamese text. Each replacement is undoable with the normal * undo stroke, and replacements clear stale candidates before triggering a fresh inference pass.
The * key (Spacebar) pressed by itself undoes the previous action (syllable entry, island entry, or candidate selection).
The bundled two-syllable dictionary is seeded exclusively from
data/two_syllable_dictionary.txt, with one normalized lexical pair per line.
The evaluation corpus is not used to construct dictionary buckets, preventing
benchmark leakage.
The training pipeline uses:
- A Python preprocessor (
preprocess_corpus.py) to normalize raw corpus text into KenLM training text. - KenLM (
lmplz+build_binary) to train and binarize a 3-gram language model.
Both are run inside a dedicated Docker training container. No manual installation of KenLM or Python packages is required on the host.
- Docker and Docker Compose installed on the host.
- A raw Vietnamese text corpus at
data/corpus-full.txt(one sentence per line).
docker compose run --rm train bash train_lm.shThis single command will:
- Preprocess
data/corpus-full.txtintodata/corpus.tok. - Train a 3-gram KenLM model (
lm.arpa). - Binarize the model into
lm.binaryusing the trie format with 8-bit quantization.
The runtime artifact is written to the project root. The training service bind-mounts the repository root so generated artifacts survive container removal:
| File | Description |
|---|---|
lm.binary |
Compiled KenLM binary model — loaded by the inference engine at startup. |
lm.binary must be present before starting headless inference; select the trained file from Android settings for on-device use.
preprocess_corpus.py is the historical training preprocessor. It:
- Lowercases the text.
- Removes non-alphanumeric characters (keeping Vietnamese diacritics).
- Normalizes whitespace.
- Writes one whitespace-normalized sentence per line.
The training script (train_lm.sh) uses these KenLM options:
lmplz -o 3 --prune 0 0 1 < data/corpus.tok > lm.arpa
build_binary -a 256 -q 8 trie lm.arpa lm.binary- 3-gram order.
- Trigram pruning: unigrams and bigrams are kept in full; trigrams with count 1 are pruned.
- Trie format with 8-bit quantization for a compact binary file.
If you prefer a local build, you will need to install the system dependencies and build KenLM yourself. Docker is strongly recommended instead.
- Rust: Latest stable toolchain.
- System Libraries:
cmake,build-essential,libboost-all-dev,zlib1g-dev,libbz2-dev,liblzma-dev. - Python 3.11+ with
tqdm(for the training script's progress display).
# 1. Build KenLM
git clone https://github.com/kpu/kenlm.git
cd kenlm && mkdir -p build && cd build && cmake .. && make -j$(nproc) && cd ../..
# 2. Build the Rust inference engine
cd inference-rs && cargo build --release && cd ..
# 3. Build the Android IME WebView bundle
npm ci && npm run buildThe Rust server retains the headless inference API and the independent practice game. It does not serve IME or dictionary UI assets and has no Plover HTTP/WebSocket proxy.
| Route | Purpose |
|---|---|
GET /health |
Reports readiness after tokenizer and model loading. |
POST /infer |
Returns inference candidates for legacy or versioned island requests. |
GET /practice, /practice/, /practice.html |
Serves the separate 60-second practice game. |
docker compose build
docker compose up -d inference
docker compose ps
curl --fail http://localhost:3000/healthThe model can take roughly 20 seconds to load. Compose reports health: starting during startup; wait for readiness before sending inference requests. A missing or invalid lm.binary prevents readiness. Check docker compose logs --tail=100 inference, and confirm the model is a regular readable file. docker compose config --quiet validates the stack. The inference service restarts unless explicitly stopped; docker compose down removes its containers and network.
For local serving:
./inference-rs/target/release/inference-rs --server --port 3000 --static-dir staticFor CLI inference through Docker:
docker compose run --rm --entrypoint ./inference-rs/target/release/inference-rs inference na0tro2dde7la1nhu0ma2khi0tro2mu0thi2no1ra6me7The headless API accepts the alternating legacy island array inside {"islands":[...]} or explicit version-2 islands:
{"version":2,"islands":[{"kind":"fixed","text":"hôm nay "},{"kind":"v7","code":"tro2dde7","mode":"compositional"}]}Use mode: "dictionary" for lexical-pair inference. Responses contain candidates, and may contain invalidV7Codes and dictionaryBucketSizes. --evaluation-stdio (or V7_EVALUATION_PROTOCOL=ndjson-v1) reads one legacy array or versioned request per line and writes one JSON response per line. --model-path selects the language model; V7_MODEL_PATH overrides it.
The compiled Rust binary expects lm.binary in the current working directory.
Pass a single raw V7 string. The engine decodes it as a standalone sentence.
./inference-rs/target/release/inference-rs na0tro2dde7la1nhu0ma2khi0tro2mu0thi2no1ra6me7Output:
Top results:
1. nay trời đẹp lắm nhưng mà khi trời mưa thì nó rất mệt
...
Inference time: Xms
Pass a JSON array of strings to interleave existing fixed text with V7 code islands. Fixed text provides context for the prediction.
Format: ["Fixed Text", "V7 Code", "Fixed Text", "V7 Code", ...]
- The array must start with a Fixed Text element (use an empty string
""if there is no preceding text). - Alternating structure: Even indices are Fixed Text, odd indices are V7 Code.
- Context Propagation: The engine "reads" fixed text to update its internal state, ensuring that subsequent V7 predictions are contextually appropriate. A question mark (
?), exclamation mark (!), or full stop (.) starts a fresh sentence state, so words before it cannot influence inference after it.
Example:
./inference-rs/target/release/inference-rs '["hôm nay ", "tro2", " rất ", "dde7"]'Output (JSON Mode): Returns a JSON array of candidate lists. Each list contains the predicted text for the corresponding V7 island.
[["trời","tròn",...], ["đẹp","đến",...]]The V7 format is a highly compressed phonetic coding system. Unlike standard Telex, it requires precise adherence to specific mapping rules for consonants, rimes, and tones.
Structure: [Consonant][RimeStart][Tone]
The input string must start with a valid consonant code. The parser matches the longest valid consonant code.
| Input Code | Vietnamese Sound | Notes |
|---|---|---|
k |
c, k | Use k for both c (ca) and k (ki). |
w |
qu | w + y -> quy, w + a -> qua. |
z |
gi | z + a -> gia. |
dd |
đ | Standard mapping. |
d |
d | Distinct from đ. |
0 |
(none) | Use 0 for words starting with a vowel (e.g., anh -> 0...). |
g |
g, gh | g handles both cases automatically. |
ng |
ng, ngh | ng handles both cases automatically. |
Standard Consonants: b, ch, h, kh, l, m, n, nh, p, ph, r, s, t, th, tr, v, x.
This is the single character that immediately follows the consonant.
- It corresponds to the first letter of the rime, normalized to its base Latin vowel (removing diacritics).
- Normalization Rule:
ă,â,a->a;ê,e->e;ô,ơ,o->o;ư,u->u;i,y->i. (Note: Bothiandyare normalized toi). - Example: For "quyết" (qu + yết), the consonant is
w(qu). The rime starts withy. Normalized:i. Input:wi. - Example: For "anh" (vowel start + anh), the consonant is
0, rime starts witha. Normalized:a. Input:0a.
Tones are represented by digits. The mapping is crucial and depends on whether the syllable ends with a stop consonant (c, ch, p, t).
Open/Nasal Syllables (ending in vowel, m, n, ng, nh):
0: Ngang (Level) - ma, an1: Sắc (Acute) - má, án2: Huyền (Grave) - mà, àn3: Hỏi (Hook) - mả, ản4: Ngã (Tilde) - mã, ãn5: Nặng (Dot) - mạ, ạn
Checked Syllables (Stop ending: c, ch, p, t): These syllables can only carry Sắc or Nặng tones. V7 separates them to improve prediction accuracy.
6: Sắc (Acute) - mát, ách, cắp7: Nặng (Dot) - mạt, ạch, cặp
| Word | Decomposition | V7 Code | Notes |
|---|---|---|---|
| nay | n + ay + Ngang |
na0 |
n matches n, a is rime start, 0 is tone. |
| trời | tr + ời + Huyền |
tro2 |
tr matches tr, o is rime start (ơ -> o), 2 is tone. |
| đẹp | đ + ẹp + Nặng (Stop) |
dde7 |
dd matches đ, e is rime start, 7 is stop-tone Nặng. |
| lắm | l + ắm + Sắc |
la1 |
l matches l, a is rime start (ă -> a), 1 is tone. |
| quốc | qu + ốc + Sắc (Stop) |
wo6 |
w matches qu, o is rime start (ố -> o), 6 is stop-tone Sắc. |
| anh | (none) + anh + Ngang |
0a0 |
0 is empty consonant, a is rime start. |
| nghiêng | ngh + iêng + Ngang |
ngi0 |
ng maps ng/ngh, i is rime start. |
| giữa | gi + ữa + Ngã |
zu4 |
z matches gi, u is rime start (ư -> u), 4 is tone. |
The separate practice game is retained at static/practice.html, served at /practice by the Rust server and packaged by practice-android. It is self-contained and does not load the IME editor bundle. See player instructions for the 60-second rounds, input modes, scoring, diligence, and local records.
This build space packages static/practice.html into a release-mode Android App Bundle (.aab). The Android app is intentionally small: it is a native fullscreen WebView shell that loads the checked-in practice page from Android assets. The build also generates the Play Console assets you need alongside the bundle.
| Field | Value |
|---|---|
| Package name | com.huynhtrankhanh.v7practice |
| App label | V7 Practice |
| Entry point | MainActivity |
| Content source | static/practice.html |
| Android output | Release App Bundle (.aab) |
| Launcher icon | Included in the bundle via mipmap-* resources |
| Play Console screenshots | Written to android-artifacts/play-store/ |
| Path | Purpose |
|---|---|
Dockerfile.practice-android |
Container image with JDK, Gradle, Android SDK, and signing helper dependencies. |
docker-compose.yml |
Defines the long-running practice-android service used by docker exec / docker compose exec. |
practice-android/app/build.gradle |
Android application config, package name, release signing config, and version inputs. |
practice-android/app/src/main/java/.../MainActivity.java |
Fullscreen WebView wrapper that loads file:///android_asset/practice.html. |
practice-android/container/build-practice-aab |
Build entrypoint that derives signing material and runs bundleRelease. |
practice-android/container/derive-v7-practice-keystore.py |
Deterministic password-to-PKCS12 signing keystore generator. |
Build the image and start the service:
docker compose build practice-android
docker compose up -d practice-androidCreate a signed release bundle:
docker compose exec practice-android build-practice-aab "1.0.0"The command can also be run with direct Docker exec:
docker exec -it v7-practice-android-1 build-practice-aab "1.0.0"Artifacts are written through the bind mount to:
android-artifacts/
For version 1.0.0, the expected outputs are:
android-artifacts/v7-practice-1.0.0.aab
android-artifacts/v7-practice-1.0.0.aab.sha256
android-artifacts/play-store/app-icon-512.png
android-artifacts/play-store/phone-portrait-1080x1920.png
android-artifacts/play-store/phone-landscape-1920x1080.png
android-artifacts/play-store/tablet-portrait-1440x2560.png
android-artifacts/play-store/tablet-landscape-2560x1440.png
The build command accepts:
build-practice-aab <versionName> [versionCode]
| Argument | Required | Meaning |
|---|---|---|
versionName |
Yes | Human-readable Android version name, for example 1.0.0. |
versionCode |
No | Integer Android version code. If omitted, digits are derived from versionName; if no digits exist, 1 is used. |
For Google Play uploads, sign with the upload key registered in Play Console (Setup > App integrity > Upload key certificate). Provide that keystore with SIGNING_STORE_FILE, SIGNING_STORE_PASSWORD, SIGNING_KEY_ALIAS, and SIGNING_KEY_PASSWORD; the build script now preserves those values instead of replacing them with a derived key. If you do not provide SIGNING_STORE_FILE, the signing password is prompted from stdin with * masking when run interactively. For automation with the derived fallback, provide V7_SIGNING_PASSWORD in the environment instead of passing the secret as a command-line argument.
Example with explicit version code:
docker compose exec practice-android build-practice-aab "1.0.0" 100The same values can be supplied via environment variables:
docker compose exec \
-e SIGNING_STORE_FILE="/workspace/secrets/play-upload.jks" \
-e SIGNING_STORE_PASSWORD="your keystore password" \
-e SIGNING_KEY_ALIAS="your upload key alias" \
-e SIGNING_KEY_PASSWORD="your key password" \
-e V7_VERSION="1.0.0" \
-e V7_VERSION_CODE="100" \
practice-android build-practice-aabThe build does not store a signing key in the repository. For Play uploads, provide the Play Console upload keystore through the SIGNING_* environment variables. If no keystore is provided, derive-v7-practice-keystore.py derives a deterministic 4096-bit RSA private key from the supplied password and writes a temporary PKCS12 keystore inside the container. Gradle uses the selected keystore for release signing.
Important consequences:
- The
.aabmust be signed with the upload certificate shown in Play Console. A different key can be cryptographically valid but still rejected by Google Play. - The build runs
jarsigner -verify -verboseafterbundleReleaseand writes the upload certificate SHA-1/SHA-256 fingerprints toandroid-artifacts/v7-practice-<version>.upload-certificate.txtso the pipeline can compare them with Play Console. - In derived-key fallback mode, the same password recreates the same 4096-bit RSA signing key and certificate (valid from 2024-01-01 through 2054-01-01), so future versions can be signed consistently only if that derived certificate has been registered as the Play upload key.
- Losing or changing the password changes the signing key and produces bundles that are not compatible as updates to an app signed with the previous password.
- The derived keystore is temporary container build material. It is not copied to
android-artifacts/and should not be committed.
practice-android/app/build.gradle copies ../static/practice.html into app/src/main/assets before Android builds. The WebView loads that local asset with:
file:///android_asset/practice.html
Rebuilding the bundle after editing static/practice.html is enough to package the latest practice page.
The build entrypoint also generates the launcher icon resources consumed by the bundle and the Play Store screenshots used for submission.
A successful build ends with Gradle's bundleRelease task, a jarsigner verification, and the bundle path plus SHA-256 checksum, for example:
BUILD SUCCESSFUL
<sha256> /workspace/artifacts/v7-practice-1.0.0.aab
AAB: /workspace/artifacts/v7-practice-1.0.0.aab
Install JavaScript dependencies with npm ci. npm run build compiles src/ime.ts into static/script.js; the Android Gradle build packages only the composition and dictionary assets. Browser automation supplies mock native bridges to exercise the APK UI; it is a test harness, not a supported browser editor.
npm run build
npm test
npm run test:android-ime
npm run test:clipboard-slots
npm run test:dictionary-layout
npm run test:practice-focus
npm run test:practice-zoomnpm run test:e2e runs the Android bridge, clipboard, dictionary layout, practice focus, and practice zoom checks together. npm run test:android-stripped-plover additionally exercises the generated bundled runtime in isolated and non-isolated WebView configurations; build its generated Android assets first. Native Android unit tests run through gradle -p ime-android testDebugUnitTest after installing the Android build dependencies.
V7PloverNativeFuzzTest exercises the real IME service, hardware routing,
InputConnection, and bundled JavaScript input protocol under Robolectric.
It shadows JNI inference and Plover RPC with deterministic responses. Campaigns
cover mode switches, editing, Telex conversion, candidate selection, clipboard,
cursor moves, editor replacement, view restarts, queued and duplicate hardware
events, and cold runtime startup. A separate campaign enters Plover once and
keeps typing without another mode switch or view restart. Plover responses grow,
shrink, disappear, and regrow, with unrelated replacements, commits, Unicode,
combining characters, and multiline text. RPC callbacks can be delayed,
reordered, duplicated, synchronous, or unsuccessful; editor callbacks can be
reentrant, omit composing bounds, or expose absent or cached selection queries.
Transient editor write/finish failures must recover without another key.
The tests compare immediate and hostile schedules at fixed checkpoints, check pure Plover and basic mode transitions against independent text models, and verify that owned callbacks never end the composition. They also reject printable keys forwarded to the host and detect stalled protocol calls. Raw V7 updates remain part of the published composition.
Run it with gradle -p ime-android testDebugUnitTest --tests '*V7PloverNativeFuzzTest'.
With Robolectric 4.16.1 and AndroidX dependencies already in the Gradle cache,
npm run test:native-ime-fuzz runs the same tests without building an APK or
requiring the Android SDK. The standalone runner uses compile-only resource IDs;
these tests do not inflate APK layouts. For a larger reproducible campaign, run
npm run test:native-ime-fuzz -- --seeds 500 --steps 200; use --first-seed N
and --seeds 1 to replay a failing seed. --scenario basic, lifecycle, or
plover selects one campaign; the default all runs all three, plus the
minimized regressions. With all campaigns, each seed executes five runs of
--steps actions (basic once, lifecycle and sustained Plover twice). Assertions
print the seed and action history. Minimized regressions preserve failures from
the recording and fuzz campaigns, including queued mode exits, unexpected
composition ends, rejected writes, and synchronous reset/inference deadlocks.
After building the host engine with cargo build --manifest-path inference-rs/Cargo.toml,
run npm run test:inference-api with a local lm.binary to verify inference,
practice routes, and the absence of editor/proxy routes. V7_INFERENCE_BINARY
and V7_TEST_MODEL_PATH select another binary or model. Rust unit tests run with
cargo test --manifest-path inference-rs/Cargo.toml --locked.
KenLM's host build needs Boost program-options, system, thread, and unit-test development packages, plus zlib, bzip2, and lzma. Docker installs these. Set KENLM_ROOT when KenLM is outside ./kenlm; its host build must contain build/lib/libkenlm.a and build/lib/libkenlm_util.a. Native and Docker inference tests need a trained model; Android additionally needs the selected document grant.
Original V7 source files in this repository are licensed under the 0BSD License.
The bundled Android distribution is a combined work that includes Stripped
Plover. The APK is conveyed under GPL-3.0-or-later and contains its complete
Corresponding Source as v7-ime-source.zip, exportable from V7 IME
settings → Save Corresponding Source. This distribution-level GPL notice
does not replace the 0BSD license on original V7 source files. Stripped Plover
and other third-party components retain their respective copyright and license
notices. See Android architecture and signed release pipeline above for distribution and source details.
This project was originally inspired by the v7 repository. While the current codebase represents a significant divergence in implementation and approach, we acknowledge the instrumental role of the original project in the development of this engine. See ACKNOWLEDGMENTS.md for more details and the original license.



