Skip to content
Closed
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

### Added
- **Two separate thumb tracks (experimental)** — an opt-in gesture mode that feeds an earlier part of a word and the part you're swiping now to the recognizer as two *separate* thumb strokes instead of splicing them into one long trail, removing the invented connector movement between them. Under Two-Thumb Typing → Recognition. (#135)
- **Redraw earlier word parts cleanly (experimental)** — optionally replaces the earlier part of a multi-part word with a tidy path through its key centres, and turns a single tap into a small swipe, so the recognizer sees one believable whole-word gesture. Includes tunable trail-speed and pause controls. (#135)

### 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 onto the two per-pointer tracks the native decoder actually reads. (#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 pointer-id / time-policy / tap-promotion knobs and a printed sweep table. Runs in CI alongside the existing native suite. (#135)
- The multi-part trail merge moved behind a pure, unit-tested `StrokeAligner` seam whose defaults reproduce the previous behaviour exactly. (#135)

### Changed
- Documented the two-thumb decoder research in `docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md`: the native decoder models two pointer tracks, track membership is decided by pointer id rather than timing, and deliberately overlapping stroke timestamps measurably corrupts the decoder's speed features. (#135)

## [0.2.0] - 2026-08-06

### Upstream
Expand Down
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