Skip to content

Release 3.9.0#84

Merged
AsafMah merged 12 commits into
mainfrom
dev
Jun 10, 2026
Merged

Release 3.9.0#84
AsafMah merged 12 commits into
mainfrom
dev

Conversation

@AsafMah

@AsafMah AsafMah commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Release 3.9.0 (versionCode 3900).

Ships everything on dev since 3.8.6 — see CHANGELOG.md / fastlane 3900.txt. Highlights:

  • Smarter learned-word trust (graduated trust), undo-word toolbar key, flag/Add/Block + Blocklist
  • HeliBoard QoL backports: HCESAR layout, touchpad edge-scroll, swipe-down-to-hide, HW-keyboard toolbar
  • Reliability: native C++ engine tests now run in CI; backspace state consolidated + golden-master corpus; unit-test gate blocking
  • Docs: CHANGELOG, README refreshed for the fork, branding cleanup

Merging this, then tag v3.9.0 triggers the signed-APK release CD.

AsafMah added 12 commits June 7, 2026 10:59
…) (#64)

A learned word in no real dictionary (main/contacts/apps/personal) could out-rank
a real dictionary word with better geometry after a single misfire. Now, as the
LAST ranking step in getSuggestionResults (after session boost, so it can't be
undone), an uncurated USER_HISTORY candidate that still outscores the best
real-dictionary candidate is CAPPED just below it — until its user-history
frequency crosses a confirmation threshold (~3 repetitions). This guarantees a
one-off junk word can't hijack a real word regardless of native score magnitude,
while a deliberately repeated new word still learns and keeps full score. When no
real candidate exists, new words are left untouched (still offerable).

- Capping (not score-scaling) avoids any dependence on native score calibration or
  sign; applied post-session-boost so the boost can't re-promote junk.
- Decision is a pure companion helper (shouldPenalizeUnconfirmedWord), unit-tested;
  uncurated check reuses isInNonHistoryDictionary; threshold is a tunable constant.
- Gated by new pref PREF_GRADUATED_TRUST (default on).

The actual ranking effect needs the native scorer, so the threshold and the
'real candidate' set want on-device playtesting.
…row (#36) (#68)

Follow-up to #58: the left-pin used natural icon width, so N icons tiled from the
left and the far-right keys couldn't reach them. Now each icon is sized to
rowWidth/iconCount (usable normal-key row span), so the N icons tile the whole row
and a swipe maps proportionally — swiping from the i-th 1/N of the row selects the
i-th icon. Threads a fixedKeyWidth into PopupKeysKeyboard.Builder (0 = default for
normal/emoji popups); dividers are dropped in that mode so the icons tile exactly.
… (#66)

* feat(toolbar): UNDO_WORD key — revert last commit to its alternatives (#35)

Adds an Undo-word toolbar key that reverts the last committed (gesture) word back
to its alternatives in the suggestion strip, modeled on the JOIN_NEXT toolbar key.

- New KeyCode.UNDO_WORD (-251, next free toolbar code); ToolbarKey.UNDO_WORD +
  getCodeForToolbarKey mapping; ic_undo / ic_undo_rounded icons; talkback string.
- InputLogic.handleUndoWord: guarded by mLastComposedWord.canRevertCommit(), reuses
  the backspace revert sequence (revertCommit + restartSuggestionsOnWordTouchedByCursor)
  so the committed word's SuggestionSpans repopulate the strip with its alternatives.

Scope: toolbar key only. The swipe-up/down assignable target (also in #35) is deferred
until the shortcut-assignment plumbing (shared with #37) exists.

Needs on-device verification that gesture-committed words restore their alternatives
(vs dictionary-fresh suggestions) — see PR notes.

* fix(toolbar): rework UNDO_WORD to non-destructive recorrection (#35)

On-device testing found the revertCommit reuse was wrong: it deletes
committedWord.length()+separator before the CURRENT cursor, but after a gesture
the trailing space is a pending PHANTOM space (not in the text), so it over-deleted
into the previous word; on stale/empty state it deleted the wrong span -> garbage.

Drop revertCommit entirely. handleUndoWord now just calls
restartSuggestionsOnWordTouchedByCursor: after a gesture the cursor sits right
after the committed word (phantom space not in text), so that word is re-opened
for re-selection with its alternatives shown — exactly like tapping a word to
re-edit it. Non-destructive (no delete -> no garbage, no over-delete), and its
existing guards no-op safely when the cursor isn't on a word / has selection /
no-spaces language / suggestions disabled.

Also: distinct icon — UNDO_WORD now uses ic_edit (UNDO already uses ic_undo).

* fix(toolbar): UNDO_WORD shows the word's real alternatives, not a fresh lookup (#35)

restartSuggestionsOnWordTouchedByCursor reads suggestion spans from the EDITOR
text, but gesture commits don't leave them there, so it fell into a fresh
typing-style lookup -> nonsense suggestions.

Read the alternatives from mLastComposedWord.mCommittedWord's in-memory
SuggestionSpan instead (the actual gesture/autocorrect candidates the original
revertCommit used). Still non-destructive: verify the committed word is exactly
before the cursor, set the composing region over it (no delete), and show those
alternatives; no-op if the word moved/changed or has no real alternatives.

* feat(toolbar): UNDO_WORD recovers the last word's alternatives after space (#35)

The real value is AFTER you've pressed space (the strip already shows alternatives
right after a word). mLastComposedWord.deactivate() runs on the space keypress, but
the object + its in-memory SuggestionSpan alternatives survive. So handleUndoWord now
finds the committed word before the cursor allowing a trailing whitespace separator,
moves the cursor back into it (setSelection), re-composes it, and shows its original
alternatives. Non-destructive (no deletion). No-ops if the last word before the cursor
isn't the committed one or it has no alternatives.

* fix(toolbar): UNDO_WORD reads alternatives from the editor via resume (#35)

Root cause of the no-ops: commitChosenWord stores the PLAIN chosenWord as
mLastComposedWord.mCommittedWord (the spans-carrying string only goes to the
editor at commitText), so reading SuggestionSpans off mCommittedWord always found
none -> size<=1 -> no-op.

The candidates actually live in the editor text's SuggestionSpans. So: move the
cursor to the end of the last word before the cursor (skip a trailing space), then
postResumeSuggestions(delay) -> MSG_RESUME_SUGGESTIONS -> restartSuggestionsOnWordTouchedByCursor,
which reads the word's editor spans after the cursor settles. Non-destructive,
race-free (uses the standard resume mechanism).
Debug capture mode: when DebugSettings.PREF_RECORD_INPUT_TRACES is on, each
completed gesture session is dumped to filesDir/input_traces/trace-<ms>.json
(InputPointers x/y/t/id + committed word + keyboard geometry/locale), for the
JUnit replay harness (#21).

- Hooked in onUpdateTailBatchInputCompleted (where committed word + InputPointers
  + keyboard are all synchronously available on the UI thread), not onEndBatchInput.
- TraceRecorder copies the pointer arrays synchronously (InputPointers isn't
  thread-safe) then writes off a single-threaded executor; app context, no IME leak.
- Default off -> a single boolean check in normal use. Format documented in KDoc.
Adds 10 backspace regression tests to InputLogicTest (the #31 safety net):
default per-char delete (composing/committed/recompose), the PR-#11
'precool' word-mash corruption guard, combining whole-word delete,
cursor-in-middle recompose, legacy fragment-pop, and two monotonicity
invariants. Assertions are observable (editor + composing text) only, so
they survive the #31 input-unit-stack refactor that removes
mGestureFragmentBoundaries. The multi-stroke gesture-extension case is
@ignore'd (not JVM-simulable; documented for on-device verification).

9 active tests green; 1 @ignore; no new failures vs the InputLogicTest
baseline (3 pre-existing env failures unchanged).
#76)

The 'Update README Badges' workflow ran daily and git-pushed straight to
main, which now fails on every run (GH006: protected branch — changes must
be a PR) since branch protection was enabled. It also fetched stats from
the wrong repo (LeanBitLab/HeliboardL) and committed static SVGs instead of
using live badges.

Remove the workflow + the generated docs/badges/*.svg, and point the README
badges at live shields.io URLs for AsafMah/LeanType (auto-update, no CI).

Note: the Download buttons still reference LeanBitLab/HeliboardL +
com.leanbitlab.leantype — left as-is (distribution/branding decision).
…tack (#31) (#77)

* refactor(inputlogic): consolidate backspace state into BackspaceUnitStack (#31)

The fragment- and whole-word-backspace length bookkeeping lived in three
fields scattered across InputLogic (mGestureFragmentBoundaries +
mLastGestureCommittedLength + mLastGestureCommittedFragmentLengths), read
and mutated by several interleaved branches — the most corruption-prone
area (PR #11 class).

Extract them into one BackspaceUnitStack: the active composing word's
fragment boundaries and the last committed gesture word's total +
per-fragment lengths, with all the boundary/pop math as named methods.
InputLogic keeps the policy (when to record/pop) and editor side effects;
the stack owns only the length bookkeeping. Pure, behaviour-preserving
extraction — same decisions, same outputs.

Adds BackspaceUnitStackTest: 17 direct unit tests for the pop/commit math
that previously had only indirect coverage via InputLogic.

Verification: :app:testOfflineDebugUnitTest --tests InputLogicTest --tests
BackspaceUnitStackTest -> 120 completed, 3 failed (pre-existing baseline:
autospace-indicator / Hangul / autocorrect-revert), 1 skipped. 0 new
failures; full #21 backspace corpus green; all 17 stack tests green.

Increment 1 of #31 (state consolidation). The committed-gesture pop paths
(gesture-only, not JVM-reachable) are migrated structurally but need
on-device confirmation; the single-plan path collapse is the follow-up.

* test(inputlogic): lock empty-fragment commit invariant for BackspaceUnitStack

A gesture word committed with no recorded fragments must still set
committedLength (arms the first-backspace whole-word delete) while keeping
the fragment list empty — the empty-list commit case flagged in review as
the easiest place to silently drop the length.
) (#81)

The native engine (app/src/main/jni) does the real gesture/dictionary/geometry
work, but its gtest suite (tests/) only had an AOSP-platform-build runner
(run-tests.sh / HostUnitTests.mk via mmm/lunch/BUILD_HOST_NATIVE_TEST) that
cannot run in gradle/NDK CI. So the engine had ZERO automated coverage — only
on-device manual testing caught a recognizer/dictionary regression.

Add a standalone host build:
- app/src/main/jni/CMakeLists.txt — compiles the engine core + the existing
  gtest suite + GoogleTest (FetchContent) into a host executable. Excludes the
  JNI bridge (needs jni.h at link); pulls JDK jni.h headers via find_package(JNI)
  (or -DLATINIME_JNI_INCLUDE for local builds). No AOSP tree needed.
- host_test_compat.h — force-included for the host build only, supplying the
  few standard headers (climits/cstdint/...) modern g++ wants but the AOSP clang
  pulled transitively. Shared sources untouched (they build fine under the NDK).
- .github/workflows/native-tests.yml — ubuntu + setup-java + cmake + ctest, run
  on changes to app/src/main/jni/**.

Verified locally (WSL g++13): 66 tests build and run, 65 pass. The 1 failure
(FormatUtilsTest.TestDetectFormatVersion) is a host-toolchain anomaly in code
that works on-device, quarantined from the gate and tracked in #80 — the harness
catching it on first run is the point: it reaches code the JVM tests cannot.

Deliverable 1 of #78 (native engine tests in CI). Deliverable 2 (gesture-replay
test over recorded TraceRecorder fixtures) follows — it needs on-device-recorded
trace fixtures.
…-hide, HW-keyboard toolbar (#74) (#79)

* feat(layouts): add HCESAR layout (#74)

* feat(touchpad): edge-scroll cursor acceleration (#74)

* feat(toolbar): swipe down on toolbar to hide keyboard (#74)

* feat(toolbar): option to show only toolbar with a hardware keyboard (#74)
* docs: add CHANGELOG, refresh README for the fork, bump to 3.9.0

- CHANGELOG.md: Keep-a-Changelog history of LeanTypeDual's own releases
  (3.8.1-3.8.6 from fastlane + the 3.9.0 work), with a coarse 'Upstream'
  baseline marker instead of per-line provenance.
- README: lead with the fork's identity + purpose (two-thumb / dual-thumb
  typing — the namesake feature, previously undocumented), accurate
  HeliBoard -> LeanType -> LeanTypeDual lineage, and the current feature
  set incl. graduated trust, blocklist, undo-word, per-dictionary control.
  Credits now attribute LeanTypeDual (AsafMah) on top of LeanType
  (LeanBitLab); sponsor kept as-is.
- Bump versionName 3.8.6 -> 3.9.0 (versionCode 3860 -> 3900) so the next
  release is a clean 3.9.0; add fastlane 3900.txt user-facing notes.
- AGENTS.md: changelog/release convention (coarse provenance, versionCode
  scheme, fastlane note on release).

Note: README Download buttons still point at LeanBitLab/HeliboardL and the
banner SVG still reads 'LeanType' — left for a separate distribution/branding
decision.

* docs(branding): point downloads at AsafMah/LeanType, banner -> LeanTypeDual

- README Download buttons now point at AsafMah/LeanType (GitHub releases +
  Obtainium); drop the F-Droid button (the fork ships as
  com.asafmah.leantypedual, not the upstream com.leanbitlab.leantype, and is
  not published on F-Droid).
- Banner SVGs (light + dark) now read 'LeanTypeDual' (Lean / Type-purple /
  Dual); alt text updated. Verified rendering (no clipping).
- Lineage/credit links to github.com/LeanBitLab kept (correct attribution).
@AsafMah AsafMah merged commit ec9d36d into main Jun 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant