Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

### Added
- **Side-by-side experimental build** — an `experimental` build type (`com.asafmah.leantypedual.exp`, shown as "LeanTypeDual EXP") that installs alongside the normal build instead of replacing it, so input experiments can be compared against a working daily driver. (#141)

### Fixed
- **Gesture typing no longer silently returns zero suggestions** when a stroke's touch points never carry pointer id 0 — reachable in two-thumb use (thumb A down, thumb B down, thumb A lifts, thumb B swipes on). Raw MotionEvent pointer ids are now renumbered in first-seen order. (#135)
- **The two-thumb recognition settings no longer appear when they cannot work.** They synthesise touch points for the native gesture decoder; the built-in fallback engine scores a single trail and ignores which thumb drew it, so applying them there corrupted the trail and produced nonsense words. The group is now gated on a loaded gesture library, and explains itself when the spacing mode leaves it inert, instead of showing controls that structurally cannot take effect. (#141)

### Changed
- Two experimental recognition modes exist behind settings — feeding the two thumbs as separate decoder tracks, and redrawing earlier word parts through key centres — but they are **off by default and not currently recommended**. On a device with a user-supplied gesture library they produce incorrect words: the decoder that actually runs is a closed third-party library, not the in-repo AOSP engine whose two-pointer-track behaviour the research measured. (#135, #144)
- Documented the two-thumb decoder research in `docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md`, including the measurement that deliberately overlapping stroke timestamps corrupts the decoder's speed features rather than helping. (#135)

### Reliability & testing
- Added a native gesture **two-pointer track harness** (`jni/tests/replay/two_pointer_track_test.cpp`) that drives the real AOSP `ProximityInfoState` on the host, with tunable knobs and a printed sweep table. Runs in CI alongside the existing native suite. Note that it exercises the in-repo engine, which is not the decoder used when a gesture library is loaded. (#135, #144)
- The multi-part trail merge moved behind a pure, unit-tested `StrokeAligner` seam whose defaults reproduce the previous behaviour exactly. (#135)

## [0.3.0] - 2026-08-20

### Upstream
Expand Down
13 changes: 13 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,19 @@ android {
signingConfig = signingConfigs.getByName("debug")
applicationIdSuffix = ".debug"
}
// Side-by-side experimental build. Its own applicationId suffix so it installs
// ALONGSIDE the normal debug build rather than replacing it, which is the point:
// experimental input changes have to be A/B'd against a working daily driver, and
// you cannot do that if installing one uninstalls the other. The IME picker label is
// overridden in src/experimental/res so the two are distinguishable there too.
create("experimental") {
isDebuggable = true
isMinifyEnabled = false
isJniDebuggable = false
signingConfig = signingConfigs.getByName("debug")
applicationIdSuffix = ".exp"
versionNameSuffix = "-exp"
}
// base.archivesBaseName = "HeliboardL_" + defaultConfig.versionName // replaced by dynamic naming below
applicationVariants.all {
val flavor = productFlavors.firstOrNull()?.name ?: ""
Expand Down
11 changes: 11 additions & 0 deletions app/src/experimental/res/values/donottranslate.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Build-type resource overrides for the "experimental" variant.

This build installs alongside the normal one (applicationId suffix ".exp"), so the two
keyboards appear together in the system input-method picker and in Settings. Without a
distinct label they are indistinguishable there, which makes an A/B test useless.
-->
<resources>
<string name="english_ime_name" translatable="false">LeanTypeDual EXP</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ void onUpdateBatchInput(
private static int sLastRecognitionPointSize = 0; // synchronized using sAggregatedPointers
private static long sLastRecognitionTime = 0; // synchronized using sAggregatedPointers

// Renumbers raw MotionEvent pointer ids onto the dense track slots the native decoder reads.
// Without this, a gesture whose points never carry id 0 (e.g. thumb A down, thumb B down,
// thumb A lifts, thumb B swipes on with id 1) leaves the decoder's track 0 unused and
// Suggest::initializeSearch bails out with zero suggestions. Guarded by sAggregatedPointers
// like the other statics here.
private static final PointerIdNormalizer sPointerIdNormalizer = new PointerIdNormalizer();

// ---- Two-thumb typing: autospace grace period (#1.2) ----
// When the last finger of a gesture lifts and the user has configured a non-zero grace
// window, we delay the actual commit (the "autospace grace period"). If another finger
Expand Down Expand Up @@ -75,8 +82,10 @@ public interface DeferredCommit {
}

private final GestureStrokeRecognitionPoints mRecognitionPoints;
private final int mPointerId;

public BatchInputArbiter(final int pointerId, final GestureStrokeRecognitionParams params) {
mPointerId = pointerId;
mRecognitionPoints = new GestureStrokeRecognitionPoints(pointerId, params);
}

Expand Down Expand Up @@ -154,6 +163,7 @@ public boolean mayStartBatchInput(final BatchInputArbiterListener listener) {
sAggregatedPointers.reset();
sLastRecognitionPointSize = 0;
sLastRecognitionTime = 0;
sPointerIdNormalizer.reset();
listener.onStartBatchInput();
}
return true;
Expand Down Expand Up @@ -184,7 +194,8 @@ public void updateBatchInputByTimer(final long syntheticMoveEventTime,
public void updateBatchInput(final long moveEventTime,
final BatchInputArbiterListener listener) {
synchronized (sAggregatedPointers) {
mRecognitionPoints.appendIncrementalBatchPoints(sAggregatedPointers);
mRecognitionPoints.appendIncrementalBatchPoints(sAggregatedPointers,
sPointerIdNormalizer.slotFor(mPointerId));
final int size = sAggregatedPointers.getPointerSize();
if (size > sLastRecognitionPointSize && mRecognitionPoints.hasRecognitionTimePast(
moveEventTime, sLastRecognitionTime)) {
Expand Down Expand Up @@ -229,7 +240,8 @@ public boolean mayEndBatchInput(final long upEventTime, final int activePointerC
final int graceMs, final BatchInputArbiterListener listener,
final DeferredCommit deferredCommit) {
synchronized (sAggregatedPointers) {
mRecognitionPoints.appendAllBatchPoints(sAggregatedPointers);
mRecognitionPoints.appendAllBatchPoints(sAggregatedPointers,
sPointerIdNormalizer.slotFor(mPointerId));
if (activePointerCount != 1) {
// Other fingers are still down — gesture continues, no commit yet.
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,20 +303,36 @@ public boolean hasRecognitionTimePast(

// TODO: Make this package private
public void appendAllBatchPoints(final InputPointers out) {
appendBatchPoints(out, getLength());
appendBatchPoints(out, getLength(), mPointerId);
}

/**
* Same as {@link #appendAllBatchPoints(InputPointers)} but stamps the points with an explicit
* track slot instead of the raw MotionEvent pointer id. See {@link PointerIdNormalizer}.
*/
public void appendAllBatchPoints(final InputPointers out, final int pointerIdOverride) {
appendBatchPoints(out, getLength(), pointerIdOverride);
}

// TODO: Make this package private
public void appendIncrementalBatchPoints(final InputPointers out) {
appendBatchPoints(out, mIncrementalRecognitionSize);
appendBatchPoints(out, mIncrementalRecognitionSize, mPointerId);
}

/**
* Same as {@link #appendIncrementalBatchPoints(InputPointers)} but stamps the points with an
* explicit track slot instead of the raw MotionEvent pointer id.
*/
public void appendIncrementalBatchPoints(final InputPointers out, final int pointerIdOverride) {
appendBatchPoints(out, mIncrementalRecognitionSize, pointerIdOverride);
}

private void appendBatchPoints(final InputPointers out, final int size) {
private void appendBatchPoints(final InputPointers out, final int size, final int pointerId) {
final int length = size - mLastIncrementalBatchSize;
if (length <= 0) {
return;
}
out.append(mPointerId, mEventTimes, mXCoordinates, mYCoordinates,
out.append(pointerId, mEventTimes, mXCoordinates, mYCoordinates,
mLastIncrementalBatchSize, length);
mLastIncrementalBatchSize = size;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* SPDX-License-Identifier: GPL-3.0-only
*/

package helium314.keyboard.keyboard.internal;

/**
* Maps raw {@link android.view.MotionEvent} pointer ids onto the dense track slots the native
* gesture decoder actually reads.
*
* <p><b>Why this exists.</b> The native decoder keeps exactly {@code MAX_POINTER_COUNT_G == 2}
* per-pointer tracks ({@code jni/src/defines.h}). {@code DicTraverseSession} seeds track <i>i</i>
* with pointer id <i>i</i>, and {@code ProximityInfoStateUtils::updateTouchPoints} keeps only the
* points whose {@code pointerIds[k] == i}. Two consequences follow, both measured in
* {@code jni/tests/replay/two_pointer_track_test.cpp}:
*
* <ul>
* <li>If <b>no</b> point carries id 0, track 0 is unused and {@code Suggest::initializeSearch}
* returns immediately — the gesture yields <b>zero suggestions</b>. This is reachable in
* ordinary two-thumb use: thumb A goes down (id 0), thumb B goes down (id 1), thumb A lifts,
* and thumb B swipes on alone still carrying id 1.</li>
* <li>Any id {@code >= 2} reaches no track at all and is silently discarded.</li>
* </ul>
*
* <p>Android assigns pointer ids as the lowest currently-free index, so ids are neither guaranteed
* to start at 0 for a given stroke nor to be contiguous. This class removes that dependency by
* renumbering ids in <b>first-seen order</b> within a gesture: the first pointer to contribute
* becomes slot 0, the second becomes slot 1, and so on.
*
* <p>For the overwhelmingly common cases the mapping is the identity (single finger 0 → 0; two
* fingers 0,1 → 0,1), so this is a no-op in normal use and only repairs the broken cases. Slots
* {@code >= 2} are still dropped by the native side exactly as before — this class deliberately
* does not merge a third finger into an existing track, which would change recognition behaviour.
*
* <p>Not thread-safe: like the rest of the batch-input machinery it is only touched from the
* keyboard view's UI thread.
*/
public final class PointerIdNormalizer {

/** Beyond this many distinct pointers in one gesture we stop renumbering and pass ids through. */
private static final int MAX_TRACKED_POINTERS = 8;

private final int[] mRawIds = new int[MAX_TRACKED_POINTERS];
private int mCount;

/** Forget every mapping; call at the start of each gesture. */
public void reset() {
mCount = 0;
}

/**
* @return the dense slot for {@code rawPointerId}, allocating one in first-seen order if this
* is the first time the id is seen in the current gesture. Returns {@code rawPointerId}
* unchanged if more than {@link #MAX_TRACKED_POINTERS} distinct pointers appear (which the
* decoder would discard anyway).
*/
public int slotFor(final int rawPointerId) {
for (int i = 0; i < mCount; i++) {
if (mRawIds[i] == rawPointerId) {
return i;
}
}
if (mCount >= MAX_TRACKED_POINTERS) {
return rawPointerId;
}
mRawIds[mCount] = rawPointerId;
return mCount++;
}

/** @return how many distinct pointers have been seen since the last {@link #reset()}. */
public int trackedPointerCount() {
return mCount;
}
}
27 changes: 6 additions & 21 deletions app/src/main/java/helium314/keyboard/latin/WordComposer.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
import helium314.keyboard.latin.common.StringUtils;
import helium314.keyboard.latin.define.DebugFlags;
import helium314.keyboard.latin.define.DecoderSpecificConstants;
import helium314.keyboard.latin.gesture.StrokeAligner;
import helium314.keyboard.latin.settings.Settings;

import java.util.ArrayList;
import java.util.Collections;
Expand Down Expand Up @@ -61,13 +63,6 @@ public final class WordComposer {
// huge time discontinuity at the prefix/swipe boundary and confuse the recognizer.
private final InputPointers mExtendBatchInputBase = new InputPointers(MAX_WORD_LENGTH);
private boolean mExtendBatchInputBaseSet;
// Inter-point interval used when synthesising timestamps for the base. Roughly the
// sampling rate of a fast hand-drawn swipe; chosen to look like natural gesture speed.
private static final int EXTEND_BASE_POINT_INTERVAL_MS = 25;
// Gap inserted between the last synthetic base point and the first real point of the
// current gesture. Pretends the user briefly paused at the prefix endpoint before
// continuing the stroke — within the recogniser's "single stroke" tolerance.
private static final int EXTEND_BASE_GAP_BEFORE_NEW_MS = 60;

// Cache these values for performance
private CharSequence mTypedWordCache;
Expand Down Expand Up @@ -285,20 +280,10 @@ public void setBatchInputPointers(final InputPointers batchPointers) {
if (mExtendBatchInputBaseSet && mExtendBatchInputBase.getPointerSize() > 0
&& batchPointers.getPointerSize() > 0) {
// Multi-part composition: feed the lib the merged trail (prior fragments +
// current gesture) with synthesised timestamps so the base looks like a
// natural continuation of the new gesture.
final int baseSize = mExtendBatchInputBase.getPointerSize();
final int[] baseX = mExtendBatchInputBase.getXCoordinates();
final int[] baseY = mExtendBatchInputBase.getYCoordinates();
final int firstNewTime = batchPointers.getTimes()[0];
final int baseLastTime = firstNewTime - EXTEND_BASE_GAP_BEFORE_NEW_MS;
final int baseFirstTime = baseLastTime - (baseSize - 1) * EXTEND_BASE_POINT_INTERVAL_MS;
mInputPointers.reset();
for (int i = 0; i < baseSize; i++) {
mInputPointers.addPointer(baseX[i], baseY[i], 0,
baseFirstTime + i * EXTEND_BASE_POINT_INTERVAL_MS);
}
mInputPointers.appendAll(batchPointers);
// current gesture). StrokeAligner owns the re-timing and the pointer-id policy —
// see docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md.
StrokeAligner.merge(mInputPointers, mExtendBatchInputBase, batchPointers,
Settings.getValues().mStrokeAlignParams);
} else {
mInputPointers.set(batchPointers);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,48 @@ public void shift(final int elementCount) {
}

/**
* Append all pointers from {@code other} to the end of this. Pointer ids are forced to
* 0 since multi-part gesture composition doesn't preserve pointer identity across
* separate strokes.
* Append all pointers from {@code other} to the end of this, forcing pointer id 0.
*
* <p>Historically this was the only merge path, which is why the decoder's second pointer
* track was never populated by multi-part composition. Prefer
* {@link #appendAll(InputPointers, int)} when the caller knows which track the points belong
* to — see {@link helium314.keyboard.latin.gesture.StrokeAligner}.
*/
public void appendAll(@NonNull final InputPointers other) {
append(0, other.mTimes, other.mXCoordinates, other.mYCoordinates, 0,
appendAll(other, 0);
}

/**
* Append all pointers from {@code other} to the end of this, stamping them with
* {@code pointerId}.
*
* <p>The native decoder keeps one {@code ProximityInfoState} per pointer id (two of them,
* {@code MAX_POINTER_COUNT_G}) and each state ingests <em>only</em> the points carrying its own
* id. So this argument decides which decoder track the appended stroke lands in. Ids outside
* {@code [0, 1]} reach no track at all.
*/
public void appendAll(@NonNull final InputPointers other, final int pointerId) {
append(pointerId, other.mTimes, other.mXCoordinates, other.mYCoordinates, 0,
other.getPointerSize());
}

/**
* Append all pointers from {@code other}, keeping each point's own pointer id.
*
* <p>Used when {@code other} is already a genuine multi-pointer stroke whose track assignment
* must survive the merge.
*/
public void appendAllPreservingIds(@NonNull final InputPointers other) {
final int length = other.getPointerSize();
if (length == 0) {
return;
}
mXCoordinates.append(other.mXCoordinates, 0, length);
mYCoordinates.append(other.mYCoordinates, 0, length);
mPointerIds.append(other.mPointerIds, 0, length);
mTimes.append(other.mTimes, 0, length);
}

public void reset() {
final int defaultCapacity = mDefaultCapacity;
mXCoordinates.reset(defaultCapacity);
Expand Down
Loading
Loading