If no point carries id 0, track 0 is unused and {@code Suggest::initializeSearch}
diff --git a/app/src/main/java/helium314/keyboard/latin/WordComposer.java b/app/src/main/java/helium314/keyboard/latin/WordComposer.java
index eea455fac..e8d93e519 100644
--- a/app/src/main/java/helium314/keyboard/latin/WordComposer.java
+++ b/app/src/main/java/helium314/keyboard/latin/WordComposer.java
@@ -18,8 +18,6 @@
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;
@@ -63,6 +61,13 @@ 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;
@@ -280,10 +285,20 @@ 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). 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);
+ // 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);
} else {
mInputPointers.set(batchPointers);
}
diff --git a/app/src/main/java/helium314/keyboard/latin/common/InputPointers.java b/app/src/main/java/helium314/keyboard/latin/common/InputPointers.java
index c20fca4ce..f1e4d0a1a 100644
--- a/app/src/main/java/helium314/keyboard/latin/common/InputPointers.java
+++ b/app/src/main/java/helium314/keyboard/latin/common/InputPointers.java
@@ -107,48 +107,15 @@ public void shift(final int elementCount) {
}
/**
- * Append all pointers from {@code other} to the end of this, forcing pointer id 0.
- *
- * 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}.
+ * 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.
*/
public void appendAll(@NonNull final InputPointers other) {
- appendAll(other, 0);
- }
-
- /**
- * Append all pointers from {@code other} to the end of this, stamping them with
- * {@code pointerId}.
- *
- *
The native decoder keeps one {@code ProximityInfoState} per pointer id (two of them,
- * {@code MAX_POINTER_COUNT_G}) and each state ingests only 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,
+ append(0, other.mTimes, other.mXCoordinates, other.mYCoordinates, 0,
other.getPointerSize());
}
- /**
- * Append all pointers from {@code other}, keeping each point's own pointer id.
- *
- *
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);
diff --git a/app/src/main/java/helium314/keyboard/latin/gesture/IdealPrefixTrailBuilder.java b/app/src/main/java/helium314/keyboard/latin/gesture/IdealPrefixTrailBuilder.java
deleted file mode 100644
index 6d9f0da95..000000000
--- a/app/src/main/java/helium314/keyboard/latin/gesture/IdealPrefixTrailBuilder.java
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package helium314.keyboard.latin.gesture;
-
-import helium314.keyboard.keyboard.Key;
-import helium314.keyboard.keyboard.Keyboard;
-import helium314.keyboard.latin.common.InputPointers;
-
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * Synthesises an "ideal" gesture trail for a composing prefix by tracing the prefix's key centres,
- * so the merged stream handed to the decoder looks like one plausible whole-word swipe.
- *
- *
Motivation: the prior-fragment base that {@link StrokeAligner} prepends is otherwise the
- * raw trail of what came before — which is sparse and un-stroke-like for a tap (a single
- * coordinate) and noisy for a partial swipe. Replacing it with a clean key-centre path gives the
- * recognizer the shape it was trained on.
- *
- *
Tap promotion. A single-letter prefix becomes a small out-and-back micro-stroke around
- * the key centre rather than a lone point, so the recognizer sees a vertex — this is the
- * "promote taps into small swipes" idea, and it is why an isolated tap coordinate no longer has to
- * masquerade as a stroke.
- *
- *
Only coordinates matter here: {@link StrokeAligner#merge} discards the base's timestamps and
- * re-synthesises them relative to the incoming stroke, so the times written below are placeholders.
- *
- *
Originally written for issue #99 (B7b) and gated to a side-by-side {@code swipetest} build;
- * it is now reachable at runtime via {@code PREF_STROKE_IDEAL_PREFIX}.
- */
-public final class IdealPrefixTrailBuilder {
-
- private IdealPrefixTrailBuilder() {}
-
- /** Roughly one sample per (keyWidth / SPACING_DIVISOR) px along each inter-key segment. */
- private static final int SPACING_DIVISOR = 4;
- /** Micro-stroke radius for a tap prefix, as a fraction of key width. */
- private static final int TAP_ARC_RADIUS_DIVISOR = 6;
- /** Fallback key width when the keyboard reports none. */
- private static final int FALLBACK_KEY_WIDTH = 40;
-
- /**
- * @return a key-centre trail for {@code word}, or {@code null} if it can't be built — an empty
- * word, no keyboard, or any letter that isn't on this keyboard. Returning null
- * rather than a partial path matters: a hole in the synthetic trail is worse for
- * recognition than the raw trail the caller falls back to.
- */
- public static InputPointers build(final String word, final Keyboard keyboard) {
- if (word == null || word.isEmpty() || keyboard == null) return null;
-
- final Map keyByCode = new HashMap<>();
- int keyWidth = 0;
- for (final Key key : keyboard.getSortedKeys()) {
- final int code = key.getCode();
- if (code <= 0 || key.isModifier() || !Character.isLetter(code)) continue;
- keyByCode.put(Character.toLowerCase(code), key);
- if (keyWidth == 0) keyWidth = key.getWidth();
- }
- if (keyByCode.isEmpty()) return null;
-
- final int len = word.length();
- final int[] cx = new int[len];
- final int[] cy = new int[len];
- int count = 0;
- for (int i = 0; i < len; ) {
- final int cp = word.codePointAt(i);
- i += Character.charCount(cp);
- final Key key = keyByCode.get(Character.toLowerCase(cp));
- if (key != null) {
- cx[count] = key.getX() + key.getWidth() / 2;
- cy[count] = key.getY() + key.getHeight() / 2;
- count++;
- continue;
- }
- if (Character.isLetter(cp) || Character.getType(cp) == Character.NON_SPACING_MARK) {
- // A letter we cannot place would leave a hole in the synthetic path, which is
- // worse than the raw trail. Bail out and let the caller fall back. Covers popup
- // letters, accented/combining forms and unsupported scripts.
- return null;
- }
- // Non-letters (apostrophes, digits, punctuation) are legitimately not on the trail.
- }
- if (count == 0) return null;
-
- final int effectiveKeyWidth = keyWidth > 0 ? keyWidth : FALLBACK_KEY_WIDTH;
- final InputPointers out = new InputPointers(64);
-
- if (count == 1) {
- // Tap prefix → small out-and-back micro-stroke so the recognizer sees a vertex
- // instead of an isolated point.
- final int r = Math.max(1, effectiveKeyWidth / TAP_ARC_RADIUS_DIVISOR);
- addPoint(out, cx[0] - r, cy[0]);
- addPoint(out, cx[0], cy[0]);
- addPoint(out, cx[0] + r, cy[0]);
- addPoint(out, cx[0], cy[0]);
- return out;
- }
-
- final int step = Math.max(1, effectiveKeyWidth / SPACING_DIVISOR);
- addPoint(out, cx[0], cy[0]);
- for (int i = 1; i < count; i++) {
- final int x0 = cx[i - 1], y0 = cy[i - 1], x1 = cx[i], y1 = cy[i];
- final double dist = Math.hypot(x1 - x0, y1 - y0);
- final int samples = Math.max(1, (int) (dist / step));
- for (int s = 1; s <= samples; s++) {
- final float t = (float) s / samples;
- addPoint(out, Math.round(x0 + (x1 - x0) * t), Math.round(y0 + (y1 - y0) * t));
- }
- }
- return out;
- }
-
- private static void addPoint(final InputPointers out, final int x, final int y) {
- // pointerId 0 / time 0: StrokeAligner re-stamps both when it merges the base.
- out.addPointer(x, y, StrokeAligner.BASE_POINTER_ID, 0);
- }
-}
diff --git a/app/src/main/java/helium314/keyboard/latin/gesture/StrokeAligner.java b/app/src/main/java/helium314/keyboard/latin/gesture/StrokeAligner.java
deleted file mode 100644
index 05f20538c..000000000
--- a/app/src/main/java/helium314/keyboard/latin/gesture/StrokeAligner.java
+++ /dev/null
@@ -1,182 +0,0 @@
-/*
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package helium314.keyboard.latin.gesture;
-
-import helium314.keyboard.latin.common.InputPointers;
-
-/**
- * Merges a multi-part word's prior-fragment trail ("the base") with the stroke currently being
- * gestured, into the single {@link InputPointers} stream handed to the gesture decoder.
- *
- * This is the seam where the fork decides what shape the decoder thinks the user drew,
- * and it is deliberately parameterised so the alternatives can be A/B'd on device. See
- * {@code docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md} for the measurements behind the defaults.
- *
- *
The two modes
- *
- * {@link Mode#CONNECTOR} (default, the historical behaviour): everything is stamped with
- * pointer id 0, so the decoder sees one long single-pointer glide. The jump from the base's last
- * point to the new stroke's first point is an implicit "connector" that the decoder reads as real
- * movement — which is where hallucinated middle letters ({@code techcolony}) come from.
- *
- * {@link Mode#DUAL_POINTER}: the base keeps pointer id 0 and the current stroke gets
- * pointer id 1, so the two land in separate decoder tracks
- * ({@code ProximityInfoState[0]} and {@code [1]}). There is then no connector to hallucinate
- * across, and the decoder's built-in two-pointer search can spell the word by alternating between
- * the tracks. Measured in {@code jni/tests/replay/two_pointer_track_test.cpp}.
- *
- *
Invariants this class must preserve
- *
- *
- * - Track 0 must be non-empty. {@code Suggest::initializeSearch} early-returns when
- * {@code ProximityInfoState(0)} is unused, yielding zero suggestions. The base always takes
- * id 0, and callers only reach the merge path with a non-empty base.
- * - Only ids 0 and 1 are ever emitted. Anything higher reaches no track at all. With
- * three or more fragments the older ones stay collapsed into track 0 (joined by connectors,
- * exactly as today) and only the newest stroke gets track 1.
- * - Timestamps stay globally monotonic. The decoder's speed and beeline features walk
- * the raw arrays across the track boundary, so a decreasing timestamp there yields a
- * negative duration and a garbage speed rate. The base is therefore always re-timed to end
- * {@code gapBeforeNewMs} before the current stroke begins — in both modes.
- * - Ids are stable for a given raw index across incremental recognition.
- * {@code checkAndReturnIsContinuousSuggestionPossible} compares x/y/time but not pointer
- * ids, so a point must never change track mid-gesture. The base is fixed for the duration of
- * a gesture and the current stroke only grows at the tail, so this holds.
- *
- *
- * Called on the input path, so it allocates nothing beyond what {@link InputPointers} itself
- * needs to grow.
- */
-public final class StrokeAligner {
-
- private StrokeAligner() {}
-
- /** Pointer id of the prior-fragment base. Must be 0 — see invariant 1. */
- public static final int BASE_POINTER_ID = 0;
- /** Pointer id of the in-flight stroke under {@link Mode#DUAL_POINTER}. */
- public static final int CURRENT_POINTER_ID = 1;
-
- public enum Mode {
- /** One merged single-pointer trail (historical behaviour). */
- CONNECTOR,
- /** Base on decoder track 0, current stroke on track 1. */
- DUAL_POINTER;
-
- /** Parses the stored preference value, falling back to {@link #CONNECTOR}. */
- public static Mode fromPrefValue(final String value) {
- if ("dual_pointer".equals(value)) return DUAL_POINTER;
- return CONNECTOR;
- }
- }
-
- /** Tunable knobs. Defaults reproduce the historical behaviour exactly. */
- public static final class Params {
- /** Inter-point interval when synthesising timestamps for the base. */
- public static final int DEFAULT_INTERVAL_MS = 25;
- /** Gap between the base's last synthetic point and the stroke's first real point. */
- public static final int DEFAULT_GAP_MS = 60;
-
- public final Mode mode;
- public final int basePointIntervalMs;
- public final int gapBeforeNewMs;
-
- public Params(final Mode mode, final int basePointIntervalMs, final int gapBeforeNewMs) {
- this.mode = mode == null ? Mode.CONNECTOR : mode;
- // Clamp to sane values: a non-positive interval would make the base's timestamps
- // non-increasing, which is exactly the negative-duration hazard invariant 3 exists to
- // avoid.
- this.basePointIntervalMs = Math.max(1, basePointIntervalMs);
- this.gapBeforeNewMs = Math.max(1, gapBeforeNewMs);
- }
-
- public static Params defaults() {
- return new Params(Mode.CONNECTOR, DEFAULT_INTERVAL_MS, DEFAULT_GAP_MS);
- }
-
- /**
- * The timing knobs are only surfaced in the UI for {@link Mode#DUAL_POINTER}, so
- * {@link Mode#CONNECTOR} pins them to the historical constants. Without this, tuning the
- * sliders in dual mode and switching back would silently leave "one joined trail" behaving
- * differently from how it always has.
- */
- int effectiveIntervalMs() {
- return mode == Mode.DUAL_POINTER ? basePointIntervalMs : DEFAULT_INTERVAL_MS;
- }
-
- int effectiveGapMs() {
- return mode == Mode.DUAL_POINTER ? gapBeforeNewMs : DEFAULT_GAP_MS;
- }
- }
-
- /**
- * Merge {@code base} and {@code current} into {@code out}, which is reset first.
- *
- *
The base's own timestamps are discarded and re-synthesised backwards from the current
- * stroke's first point, because base coordinates can come from taps (which carry a {@code 0}
- * time sentinel) or from an earlier gesture on an unrelated clock. Only the base's geometry is
- * meaningful.
- *
- * @param out receives the merged stream; must not alias {@code base} or {@code current}.
- * @param base prior fragments' trail. If empty, {@code current} is copied through unchanged.
- * @param current the stroke being gestured now.
- * @param params tuning knobs; {@code null} means {@link Params#defaults()}.
- */
- public static void merge(final InputPointers out, final InputPointers base,
- final InputPointers current, final Params params) {
- final Params p = params == null ? Params.defaults() : params;
- final int baseSize = base == null ? 0 : base.getPointerSize();
- final int currentSize = current == null ? 0 : current.getPointerSize();
-
- if (baseSize == 0 || currentSize == 0) {
- // Nothing to merge — a lone stroke keeps whatever pointer ids it already carries, so
- // genuinely simultaneous two-thumb input is untouched by this class.
- out.reset();
- if (currentSize > 0) {
- out.set(current);
- } else if (baseSize > 0) {
- out.set(base);
- }
- return;
- }
-
- final int[] baseX = base.getXCoordinates();
- final int[] baseY = base.getYCoordinates();
- final int intervalMs = p.effectiveIntervalMs();
- final int firstNewTime = current.getTimes()[0];
- final int baseLastTime = firstNewTime - p.effectiveGapMs();
- final int baseFirstTime = baseLastTime - (baseSize - 1) * intervalMs;
-
- out.reset();
- for (int i = 0; i < baseSize; i++) {
- out.addPointer(baseX[i], baseY[i], BASE_POINTER_ID, baseFirstTime + i * intervalMs);
- }
-
- if (p.mode != Mode.DUAL_POINTER) {
- out.appendAll(current, BASE_POINTER_ID);
- return;
- }
- // Dual-pointer: the current stroke normally takes track 1 wholesale. But if it is ITSELF a
- // simultaneous two-thumb stroke it already occupies both tracks, and flattening it onto
- // track 1 would destroy that structure. In that case keep its own ids and let the base
- // share track 0 — which reads coherently as "track 0 = this thumb plus the word so far".
- if (isMultiPointer(current)) {
- out.appendAllPreservingIds(current);
- } else {
- out.appendAll(current, CURRENT_POINTER_ID);
- }
- }
-
- /** @return true if {@code pointers} carries more than one distinct pointer id. */
- private static boolean isMultiPointer(final InputPointers pointers) {
- final int size = pointers.getPointerSize();
- if (size < 2) return false;
- final int[] ids = pointers.getPointerIds();
- final int first = ids[0];
- for (int i = 1; i < size; i++) {
- if (ids[i] != first) return true;
- }
- return false;
- }
-}
diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
index 80312df1e..c6f57181f 100644
--- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
+++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
@@ -54,7 +54,6 @@
import helium314.keyboard.latin.common.StringUtilsKt;
import helium314.keyboard.latin.common.SuggestionSpanUtilsKt;
import helium314.keyboard.latin.define.DebugFlags;
-import helium314.keyboard.latin.gesture.IdealPrefixTrailBuilder;
import helium314.keyboard.latin.settings.Settings;
import helium314.keyboard.latin.settings.SettingsValues;
import helium314.keyboard.latin.settings.SpacingAndPunctuations;
@@ -825,17 +824,7 @@ public void onStartBatchInput(final SettingsValues settingsValues,
// word simply stays open until the user taps space), see
// SettingsValues#isMultipartComposeActive.
if (settingsValues.isMultipartComposeActive()) {
- // With the ideal-prefix knob on, replace the raw prior trail with a clean
- // key-centre path for the composing prefix (and promote a one-letter prefix
- // to a micro-stroke). Falls back to the raw trail whenever the synthetic one
- // can't be built, so a fragment is never lost.
- InputPointers base = null;
- if (settingsValues.mStrokeIdealPrefix) {
- base = IdealPrefixTrailBuilder.build(mWordComposer.getTypedWord(),
- keyboardSwitcher.getKeyboard());
- }
- mWordComposer.setExtendBatchInputBase(
- base != null ? base : mWordComposer.getInputPointers());
+ mWordComposer.setExtendBatchInputBase(mWordComposer.getInputPointers());
}
} else if (mWordComposer.isSingleLetter() && !isInlineEmojiSearchAction()) {
// We auto-correct the previous (typed, not gestured) string iff it's one
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
index 4bfd52edc..637b7eef6 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
@@ -200,10 +200,6 @@ object Defaults {
const val PREF_MULTIPART_FULL_WORD_SUGGESTIONS = true
const val PREF_MULTIPART_TAP_SEED_GESTURE = true
const val PREF_MULTIPART_RERECOGNIZE_TAPS = false
- const val PREF_STROKE_ALIGN_MODE = "connector"
- const val PREF_STROKE_ALIGN_INTERVAL_MS = 25
- const val PREF_STROKE_ALIGN_GAP_MS = 60
- const val PREF_STROKE_IDEAL_PREFIX = false
const val PREF_SHOW_SETUP_WIZARD_ICON = true
const val PREF_USE_CONTACTS = false
const val PREF_USE_APPS = false
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
index 233deb152..b39709a0f 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
@@ -218,12 +218,6 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
// re-recognize the whole word, instead of literally appending it to a (possibly
// mis-resolved) fragment. Makes a slow tap-after-swipe behave like a fast one. Default off.
public static final String PREF_MULTIPART_RERECOGNIZE_TAPS = "multipart_rerecognize_taps";
- // Stroke alignment (#135): how the prior-fragment base and the in-flight stroke are merged
- // before the decoder sees them. See docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md.
- public static final String PREF_STROKE_ALIGN_MODE = "stroke_align_mode";
- public static final String PREF_STROKE_ALIGN_INTERVAL_MS = "stroke_align_interval_ms";
- public static final String PREF_STROKE_ALIGN_GAP_MS = "stroke_align_gap_ms";
- public static final String PREF_STROKE_IDEAL_PREFIX = "stroke_ideal_prefix";
public static final String PREF_SHOW_SETUP_WIZARD_ICON = "show_setup_wizard_icon";
public static final String PREF_USE_CONTACTS = "use_contacts";
public static final String PREF_USE_APPS = "use_apps";
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
index 0ebc908b0..2710177ca 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
@@ -25,7 +25,6 @@
import helium314.keyboard.latin.R;
import helium314.keyboard.latin.RichInputMethodManager;
import helium314.keyboard.latin.common.Colors;
-import helium314.keyboard.latin.gesture.StrokeAligner;
import helium314.keyboard.latin.permissions.PermissionsUtil;
import helium314.keyboard.latin.utils.InputTypeUtils;
import helium314.keyboard.latin.utils.JniUtils;
@@ -159,9 +158,6 @@ public class SettingsValues {
public final boolean mMultipartFullWordSuggestions;
public final boolean mMultipartTapSeedGesture;
public final boolean mMultipartRerecognizeTaps;
- // Stroke alignment (#135): pre-built so the input path never parses prefs per gesture.
- public final StrokeAligner.Params mStrokeAlignParams;
- public final boolean mStrokeIdealPrefix;
public final boolean mSlidingKeyInputPreviewEnabled;
public final boolean mRecordInputTraces;
public final int mKeyLongpressTimeout;
@@ -498,16 +494,6 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
mMultipartRerecognizeTaps = prefs.getBoolean(
Settings.PREF_MULTIPART_RERECOGNIZE_TAPS,
Defaults.PREF_MULTIPART_RERECOGNIZE_TAPS);
- mStrokeAlignParams = new StrokeAligner.Params(
- StrokeAligner.Mode.fromPrefValue(prefs.getString(
- Settings.PREF_STROKE_ALIGN_MODE,
- Defaults.PREF_STROKE_ALIGN_MODE)),
- prefs.getInt(Settings.PREF_STROKE_ALIGN_INTERVAL_MS,
- Defaults.PREF_STROKE_ALIGN_INTERVAL_MS),
- prefs.getInt(Settings.PREF_STROKE_ALIGN_GAP_MS,
- Defaults.PREF_STROKE_ALIGN_GAP_MS));
- mStrokeIdealPrefix = prefs.getBoolean(Settings.PREF_STROKE_IDEAL_PREFIX,
- Defaults.PREF_STROKE_IDEAL_PREFIX);
mSuggestionStripHiddenPerUserSettings = mToolbarMode == ToolbarMode.HIDDEN
|| mToolbarMode == ToolbarMode.TOOLBAR_KEYS;
final boolean moreAutoCorrection = prefs.getBoolean(Settings.PREF_MORE_AUTO_CORRECTION,
diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt
index 0a4c4f58b..c56f0cc96 100644
--- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt
+++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt
@@ -160,8 +160,6 @@ object SettingsWithoutKey {
const val DEBUG_SETTINGS = "screen_debug"
const val LOAD_GESTURE_LIB = "load_gesture_library"
const val TWO_THUMB_SPACING_MODE = "two_thumb_spacing_mode"
- const val TWO_THUMB_RECOGNITION_NEEDS_NATIVE_LIB = "two_thumb_recognition_needs_native_lib"
- const val TWO_THUMB_RECOGNITION_NEEDS_MULTIPART = "two_thumb_recognition_needs_multipart"
const val TWO_THUMB_BACKSPACE_BEHAVIOR = "two_thumb_backspace_behavior"
const val BACKGROUND_IMAGE = "background_image"
const val BACKGROUND_IMAGE_LANDSCAPE = "background_image_landscape"
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
index 14f86df7e..e73389788 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
@@ -59,7 +59,6 @@ fun TwoThumbTypingScreen(
val backspaceBehavior = currentBackspaceBehavior(prefs)
val dualThumbHinting = prefs.getBoolean(Settings.PREF_GESTURE_DUAL_THUMB_HINTING, Defaults.PREF_GESTURE_DUAL_THUMB_HINTING)
val debugDrawPoints = prefs.getBoolean(Settings.PREF_GESTURE_DEBUG_DRAW_POINTS, Defaults.PREF_GESTURE_DEBUG_DRAW_POINTS)
- val strokeAlignMode = prefs.getString(Settings.PREF_STROKE_ALIGN_MODE, Defaults.PREF_STROKE_ALIGN_MODE)
val items = buildList {
add(R.string.settings_category_two_thumb_typing_words)
@@ -83,30 +82,6 @@ fun TwoThumbTypingScreen(
}
add(R.string.settings_category_two_thumb_typing_recognition)
- // Everything in this group synthesises or re-labels the points handed to the gesture
- // decoder, and only the native decoder acts on them: it partitions points by pointer id
- // and has its own scoring. The Java fallback engine ignores pointer ids and scores a
- // single trail, so feeding it invented connector points or a synthetic key-centre prefix
- // does not improve recognition -- it corrupts the trail and produces nonsense words.
- // So gate the whole group, and say why rather than silently showing nothing.
- if (JniUtils.sHaveNativeGestureLib) {
- // These only take effect during multi-part word composition, and that is armed by
- // the spacing mode above (manual spacing, or a non-zero combining grace). At the
- // default spacing mode StrokeAligner is never reached at all -- see
- // WordComposer.setBatchInputPointers -- so the settings would appear to do nothing.
- // Show them, but say so, rather than letting someone conclude the feature is broken.
- if (!nonNormalSpacing) {
- add(SettingsWithoutKey.TWO_THUMB_RECOGNITION_NEEDS_MULTIPART)
- }
- add(Settings.PREF_STROKE_ALIGN_MODE)
- add(Settings.PREF_STROKE_IDEAL_PREFIX)
- if (strokeAlignMode == "dual_pointer") {
- add(Settings.PREF_STROKE_ALIGN_INTERVAL_MS)
- add(Settings.PREF_STROKE_ALIGN_GAP_MS)
- }
- } else {
- add(SettingsWithoutKey.TWO_THUMB_RECOGNITION_NEEDS_NATIVE_LIB)
- }
add(Settings.PREF_GESTURE_DUAL_THUMB_HINTING)
if (dualThumbHinting) {
add(Settings.PREF_GESTURE_DUAL_THUMB_MIDLINE_PCT)
@@ -147,18 +122,6 @@ fun TwoThumbTypingScreen(
}
fun createTwoThumbTypingSettings(context: Context) = listOf(
- // Shown instead of the recognition settings when no native gesture library is loaded.
- // Those settings synthesise points for the native decoder; the Java fallback engine scores
- // a single trail and ignores pointer ids, so applying them there corrupts the trail and
- // produces nonsense words. Saying so beats silently rendering an empty category.
- Setting(context, SettingsWithoutKey.TWO_THUMB_RECOGNITION_NEEDS_NATIVE_LIB,
- R.string.two_thumb_recognition_needs_lib, R.string.two_thumb_recognition_needs_lib_summary) { def ->
- Preference(name = def.title, description = def.description, enabled = false, onClick = { })
- },
- Setting(context, SettingsWithoutKey.TWO_THUMB_RECOGNITION_NEEDS_MULTIPART,
- R.string.two_thumb_recognition_needs_multipart, R.string.two_thumb_recognition_needs_multipart_summary) { def ->
- Preference(name = def.title, description = def.description, enabled = false, onClick = { })
- },
Setting(context, SettingsWithoutKey.TWO_THUMB_SPACING_MODE,
R.string.two_thumb_spacing_mode, R.string.two_thumb_spacing_mode_summary) {
TwoThumbSpacingModePreference(it)
@@ -238,37 +201,6 @@ fun createTwoThumbTypingSettings(context: Context) = listOf(
R.string.two_thumb_point_hinting, R.string.two_thumb_point_hinting_summary) {
SwitchPreference(it, Defaults.PREF_GESTURE_DUAL_THUMB_HINTING)
},
- Setting(context, Settings.PREF_STROKE_ALIGN_MODE,
- R.string.stroke_align_mode, R.string.stroke_align_mode_summary) { def -> val items = listOf(
- stringResource(R.string.stroke_align_mode_connector) to "connector",
- stringResource(R.string.stroke_align_mode_dual_pointer) to "dual_pointer",
- )
- ListPreference(def, items, Defaults.PREF_STROKE_ALIGN_MODE)
- },
- Setting(context, Settings.PREF_STROKE_IDEAL_PREFIX,
- R.string.stroke_ideal_prefix, R.string.stroke_ideal_prefix_summary) {
- SwitchPreference(it, Defaults.PREF_STROKE_IDEAL_PREFIX)
- },
- Setting(context, Settings.PREF_STROKE_ALIGN_INTERVAL_MS,
- R.string.stroke_align_interval, R.string.stroke_align_interval_summary) { def ->
- SliderPreference(
- name = def.title,
- key = def.key,
- default = Defaults.PREF_STROKE_ALIGN_INTERVAL_MS,
- range = 5f..60f,
- description = { value -> "${value.toInt()} ms" }
- )
- },
- Setting(context, Settings.PREF_STROKE_ALIGN_GAP_MS,
- R.string.stroke_align_gap, R.string.stroke_align_gap_summary) { def ->
- SliderPreference(
- name = def.title,
- key = def.key,
- default = Defaults.PREF_STROKE_ALIGN_GAP_MS,
- range = 5f..200f,
- description = { value -> "${value.toInt()} ms" }
- )
- },
Setting(context, Settings.PREF_GESTURE_DUAL_THUMB_MIDLINE_PCT, R.string.gesture_dual_thumb_midline) { def ->
SliderPreference(
name = def.title,
diff --git a/app/src/main/jni/tests/replay/two_pointer_track_test.cpp b/app/src/main/jni/tests/replay/two_pointer_track_test.cpp
deleted file mode 100644
index 06e00728a..000000000
--- a/app/src/main/jni/tests/replay/two_pointer_track_test.cpp
+++ /dev/null
@@ -1,424 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-//
-// Two-pointer track experiment harness — "does the AOSP gesture pipeline actually ingest two
-// simultaneous strokes as two tracks, and what does pointer-id assignment do to that?"
-//
-// WHY THIS EXISTS
-// ---------------
-// LeanType composes one word out of several thumb fragments (tap->swipe, swipe->swipe). Today
-// that is done SPATIALLY: WordComposer.setBatchInputPointers prepends the prior fragment's trail,
-// re-timed, and InputPointers.appendAll() forces EVERY point to pointer id 0, so the recognizer
-// sees one long single-pointer glide with a synthetic connector.
-//
-// But AOSP models TWO pointer tracks for gesture input (defines.h MAX_POINTER_COUNT_G == 2):
-// DicTraverseSession holds ProximityInfoState[2] and seeds state i with pointerId i
-// (dic_traverse_session.cpp initializeProximityInfoStates), and updateTouchPoints() keeps only the
-// points whose pointerIds[k] == pointerId (proximity_info_state_utils.cpp). So pointer id -- not
-// timing -- is what splits strokes into tracks.
-//
-// These tests drive the REAL ProximityInfoState (the same open AOSP preprocessing that is compiled
-// into libjni_latinimegoogle.so) so the claims are measured, not inferred. What they CANNOT do is
-// produce a recognized word: this tree has no gesture suggest policy
-// (gesture_suggest_policy_factory.cpp returns null), so decoding quality is still device-only.
-//
-// TUNABLE: see TrackParams below -- pointer-id assignment, time policy, overlap, gap/interval, and
-// tap->micro-stroke promotion are all knobs, and `TwoPointerSweep` prints a table across them.
-
-#include
-
-#include
-#include
-#include
-#include
-
-#include "defines.h"
-#include "suggest/core/layout/proximity_info.h"
-#include "suggest/core/layout/proximity_info_state.h"
-#include "suggest/policyimpl/typing/scoring_params.h"
-
-namespace latinime {
-namespace replay {
-namespace {
-
-// ---------------------------------------------------------------------------
-// Keyboard model: 1080x310 QWERTY, same geometry as GestureReplayHostSeamTest.
-// ---------------------------------------------------------------------------
-
-constexpr int kKeyboardWidth = 1080;
-constexpr int kKeyboardHeight = 310;
-constexpr int kGridWidth = 32;
-constexpr int kGridHeight = 16;
-constexpr int kKeyWidth = 108;
-constexpr int kKeyHeight = 90;
-constexpr int kKeyCount = 26;
-constexpr const char *kLetters = "qwertyuiopasdfghjklzxcvbnm";
-
-struct KeyGeometry {
- int xs[kKeyCount], ys[kKeyCount], widths[kKeyCount], heights[kKeyCount], codes[kKeyCount];
- float sweetXs[kKeyCount], sweetYs[kKeyCount], radii[kKeyCount];
-};
-
-KeyGeometry buildKeyGeometry() {
- KeyGeometry g{};
- for (int i = 0; i < kKeyCount; ++i) {
- const int row = i < 10 ? 0 : (i < 19 ? 1 : 2);
- const int col = i < 10 ? i : (i < 19 ? i - 10 : i - 19);
- const int rowOffset = row == 0 ? 0 : (row == 1 ? kKeyWidth / 2 : kKeyWidth);
- g.xs[i] = rowOffset + col * kKeyWidth;
- g.ys[i] = row * kKeyHeight;
- g.widths[i] = kKeyWidth;
- g.heights[i] = kKeyHeight;
- g.codes[i] = kLetters[i];
- g.sweetXs[i] = g.xs[i] + kKeyWidth / 2.0f;
- g.sweetYs[i] = g.ys[i] + kKeyHeight / 2.0f;
- g.radii[i] = kKeyWidth / 2.0f;
- }
- return g;
-}
-
-// Owns the arrays ProximityInfo borrows.
-class Qwerty {
- public:
- Qwerty()
- : mGeom(buildKeyGeometry()),
- mProximityChars(kGridWidth * kGridHeight * MAX_PROXIMITY_CHARS_SIZE,
- NOT_A_CODE_POINT),
- mInfo(kKeyboardWidth, kKeyboardHeight, kGridWidth, kGridHeight, kKeyWidth, kKeyHeight,
- mProximityChars.data(), static_cast(mProximityChars.size()), kKeyCount,
- mGeom.xs, mGeom.ys, mGeom.widths, mGeom.heights, mGeom.codes,
- mGeom.sweetXs, mGeom.sweetYs, mGeom.radii) {}
-
- const ProximityInfo *info() const { return &mInfo; }
-
- void centerOf(const char c, int *outX, int *outY) const {
- for (int i = 0; i < kKeyCount; ++i) {
- if (kLetters[i] == c) {
- *outX = mGeom.xs[i] + kKeyWidth / 2;
- *outY = mGeom.ys[i] + kKeyHeight / 2;
- return;
- }
- }
- *outX = -1;
- *outY = -1;
- }
-
- private:
- KeyGeometry mGeom;
- std::vector mProximityChars;
- ProximityInfo mInfo;
-};
-
-// ---------------------------------------------------------------------------
-// TUNABLE PARAMETERS <-- the knobs to play with
-// ---------------------------------------------------------------------------
-
-struct TrackParams {
- // How pointer ids are assigned to the two fragments.
- enum PointerMode {
- ALL_ZERO, // today's behaviour: InputPointers.appendAll() forces id 0 for everything
- SPLIT_0_1, // the proposal: fragment A -> id 0, fragment B -> id 1
- SPLIT_1_0, // reversed, to test the "state 0 must be used" constraint
- ALL_ONE, // pathological: nothing carries id 0
- ALL_TWO, // pathological: a third finger (id >= 2)
- };
- // How the two fragments are laid out on the time axis.
- enum TimeMode {
- GLOBAL_MONOTONIC, // B starts after A ends (today, via the re-timed extend base)
- PER_POINTER_RESTART, // B's clock restarts at 0 (what raw per-stroke stamps look like)
- OVERLAPPED, // B overlaps A by overlapPct of A's duration ("simultaneous")
- };
-
- PointerMode pointerMode = ALL_ZERO;
- TimeMode timeMode = GLOBAL_MONOTONIC;
- int overlapPct = 0; // OVERLAPPED only: 0 = sequential, 100 = fully co-timed
- int gapMs = 60; // WordComposer.EXTEND_BASE_GAP_BEFORE_NEW_MS
- int intervalMs = 25; // WordComposer.EXTEND_BASE_POINT_INTERVAL_MS
- int samplesPerKeyHop = 4; // densification along each inter-key segment
- // Tap -> micro-stroke promotion (IdealPrefixTrailBuilder, issue #99/B7b).
- bool promoteTaps = true;
- int tapArcRadiusDivisor = 6; // radius = keyWidth / divisor
-};
-
-struct Trace {
- std::vector xs, ys, times, ids;
- int fragmentASize = 0;
- int size() const { return static_cast(xs.size()); }
-};
-
-// Trace a word's key centres, densified — mirrors IdealPrefixTrailBuilder.build().
-void appendWordPath(const Qwerty &kb, const std::string &word, const TrackParams ¶ms,
- std::vector *xs, std::vector *ys) {
- std::vector cx, cy;
- for (const char c : word) {
- int x = 0, y = 0;
- kb.centerOf(c, &x, &y);
- if (x < 0) continue;
- cx.push_back(x);
- cy.push_back(y);
- }
- if (cx.empty()) return;
- if (cx.size() == 1) {
- if (params.promoteTaps) {
- // Out-and-back micro-stroke so the recognizer sees a vertex, not a lone point.
- const int r = std::max(1, kKeyWidth / std::max(1, params.tapArcRadiusDivisor));
- const int pxs[4] = {cx[0] - r, cx[0], cx[0] + r, cx[0]};
- for (int i = 0; i < 4; ++i) {
- xs->push_back(pxs[i]);
- ys->push_back(cy[0]);
- }
- } else {
- xs->push_back(cx[0]);
- ys->push_back(cy[0]);
- }
- return;
- }
- xs->push_back(cx[0]);
- ys->push_back(cy[0]);
- for (std::size_t i = 1; i < cx.size(); ++i) {
- const int steps = std::max(1, params.samplesPerKeyHop);
- for (int s = 1; s <= steps; ++s) {
- const float t = static_cast(s) / steps;
- xs->push_back(static_cast(std::lround(cx[i - 1] + (cx[i] - cx[i - 1]) * t)));
- ys->push_back(static_cast(std::lround(cy[i - 1] + (cy[i] - cy[i - 1]) * t)));
- }
- }
-}
-
-Trace buildTwoFragmentTrace(const Qwerty &kb, const std::string &fragA, const std::string &fragB,
- const TrackParams ¶ms) {
- Trace t;
- std::vector ax, ay, bx, by;
- appendWordPath(kb, fragA, params, &ax, &ay);
- appendWordPath(kb, fragB, params, &bx, &by);
-
- const int nA = static_cast(ax.size());
- const int nB = static_cast(bx.size());
- t.fragmentASize = nA;
-
- // Fragment A always runs 0, interval, 2*interval, ...
- const int aDuration = std::max(0, (nA - 1) * params.intervalMs);
- int bStart = 0;
- switch (params.timeMode) {
- case TrackParams::GLOBAL_MONOTONIC:
- bStart = aDuration + params.gapMs;
- break;
- case TrackParams::PER_POINTER_RESTART:
- bStart = 0;
- break;
- case TrackParams::OVERLAPPED:
- bStart = aDuration - (aDuration * params.overlapPct) / 100;
- break;
- }
-
- int idA = 0, idB = 0;
- switch (params.pointerMode) {
- case TrackParams::ALL_ZERO: idA = 0; idB = 0; break;
- case TrackParams::SPLIT_0_1: idA = 0; idB = 1; break;
- case TrackParams::SPLIT_1_0: idA = 1; idB = 0; break;
- case TrackParams::ALL_ONE: idA = 1; idB = 1; break;
- case TrackParams::ALL_TWO: idA = 2; idB = 2; break;
- }
-
- for (int i = 0; i < nA; ++i) {
- t.xs.push_back(ax[i]);
- t.ys.push_back(ay[i]);
- t.times.push_back(i * params.intervalMs);
- t.ids.push_back(idA);
- }
- for (int i = 0; i < nB; ++i) {
- t.xs.push_back(bx[i]);
- t.ys.push_back(by[i]);
- t.times.push_back(bStart + i * params.intervalMs);
- t.ids.push_back(idB);
- }
- return t;
-}
-
-struct TrackStats {
- bool used = false;
- int sampledSize = 0;
- int minRawIndex = -1;
- int maxRawIndex = -1;
- float minSpeedRate = 0.0f;
-};
-
-// Drive the REAL AOSP preprocessing for one pointer track.
-TrackStats analyzeTrack(const Qwerty &kb, const Trace &trace, const int pointerId) {
- // Heap-allocated: ProximityInfoState is large.
- auto state = std::unique_ptr(new ProximityInfoState());
- const std::vector locale;
- std::vector inputCodes(trace.size(), NOT_A_CODE_POINT);
-
- state->initInputParams(pointerId, ScoringParams::MAX_SPATIAL_DISTANCE, kb.info(),
- inputCodes.data(), trace.size(), trace.xs.data(), trace.ys.data(), trace.times.data(),
- trace.ids.data(), true /* isGeometric */, &locale);
-
- TrackStats s;
- s.used = state->isUsed();
- s.sampledSize = state->size();
- for (int i = 0; i < s.sampledSize; ++i) {
- const int raw = state->getInputIndexOfSampledPoint(i);
- if (s.minRawIndex < 0 || raw < s.minRawIndex) s.minRawIndex = raw;
- if (raw > s.maxRawIndex) s.maxRawIndex = raw;
- const float rate = state->getSpeedRate(i);
- if (i == 0 || rate < s.minSpeedRate) s.minSpeedRate = rate;
- }
- return s;
-}
-
-// "technology" split the way LeanType composes it: swipe "tech", then swipe "nology".
-const char *kFragA = "tech";
-const char *kFragB = "nology";
-
-// =============================================================================
-// 1. Today's behaviour: everything is pointer 0, so the second track is dead.
-// =============================================================================
-
-TEST(TwoPointerTrackTest, AllPointsPointerZeroLeavesSecondTrackUnused) {
- const Qwerty kb;
- TrackParams params;
- params.pointerMode = TrackParams::ALL_ZERO;
- const Trace trace = buildTwoFragmentTrace(kb, kFragA, kFragB, params);
-
- const TrackStats t0 = analyzeTrack(kb, trace, 0);
- const TrackStats t1 = analyzeTrack(kb, trace, 1);
-
- EXPECT_TRUE(t0.used) << "track 0 must absorb the whole merged trail";
- EXPECT_GT(t0.sampledSize, 0);
- // Track 0 spans BOTH fragments — one long glide with a connector jump in the middle.
- EXPECT_LT(t0.minRawIndex, trace.fragmentASize);
- EXPECT_GE(t0.maxRawIndex, trace.fragmentASize);
-
- // This is the finding: InputPointers.appendAll()'s hardcoded id 0 makes the decoder's
- // second track permanently unused, so the whole multi-part problem has to be solved
- // spatially (connectors) instead.
- EXPECT_FALSE(t1.used) << "track 1 must be empty when every point carries id 0";
- EXPECT_EQ(0, t1.sampledSize);
-}
-
-// =============================================================================
-// 2. The proposal: split ids 0/1 and BOTH native tracks light up.
-// =============================================================================
-
-TEST(TwoPointerTrackTest, SplitPointerIdsPopulateBothTracks) {
- const Qwerty kb;
- TrackParams params;
- params.pointerMode = TrackParams::SPLIT_0_1;
- const Trace trace = buildTwoFragmentTrace(kb, kFragA, kFragB, params);
-
- const TrackStats t0 = analyzeTrack(kb, trace, 0);
- const TrackStats t1 = analyzeTrack(kb, trace, 1);
-
- ASSERT_TRUE(t0.used);
- ASSERT_TRUE(t1.used) << "track 1 SHOULD be populated once fragment B carries pointer id 1";
- EXPECT_GT(t0.sampledSize, 0);
- EXPECT_GT(t1.sampledSize, 0);
-
- // Each track sees only its own fragment: no connector, no spatial jump.
- EXPECT_LT(t0.maxRawIndex, trace.fragmentASize) << "track 0 must not contain fragment B points";
- EXPECT_GE(t1.minRawIndex, trace.fragmentASize) << "track 1 must not contain fragment A points";
-}
-
-// =============================================================================
-// 3. Pathological id assignments (why normalisation is mandatory).
-// =============================================================================
-
-// suggest.cpp: `if (!traverseSession->getProximityInfoState(0)->isUsed()) return;`
-// If no point carries id 0 the whole search bails out and returns zero suggestions.
-TEST(TwoPointerTrackTest, NoPointerZeroLeavesTrackZeroEmpty) {
- const Qwerty kb;
- TrackParams params;
- params.pointerMode = TrackParams::ALL_ONE;
- const Trace trace = buildTwoFragmentTrace(kb, kFragA, kFragB, params);
-
- EXPECT_FALSE(analyzeTrack(kb, trace, 0).used)
- << "track 0 empty => Suggest::initializeSearch early-returns => no suggestions";
- EXPECT_TRUE(analyzeTrack(kb, trace, 1).used);
-}
-
-// Only states 0 and 1 exist (MAX_POINTER_COUNT_G == 2), so a third finger is dropped silently.
-TEST(TwoPointerTrackTest, PointerIdTwoIsSilentlyDropped) {
- const Qwerty kb;
- TrackParams params;
- params.pointerMode = TrackParams::ALL_TWO;
- const Trace trace = buildTwoFragmentTrace(kb, kFragA, kFragB, params);
-
- EXPECT_FALSE(analyzeTrack(kb, trace, 0).used);
- EXPECT_FALSE(analyzeTrack(kb, trace, 1).used) << "id >= 2 reaches no track at all";
-}
-
-// =============================================================================
-// 4. Time policy: does it change track membership at all?
-// =============================================================================
-
-TEST(TwoPointerTrackTest, TimePolicyDoesNotChangeTrackMembership) {
- const Qwerty kb;
- const TrackParams::TimeMode modes[] = {TrackParams::GLOBAL_MONOTONIC,
- TrackParams::PER_POINTER_RESTART, TrackParams::OVERLAPPED};
-
- int baselineT0 = -1, baselineT1 = -1;
- for (const auto mode : modes) {
- TrackParams params;
- params.pointerMode = TrackParams::SPLIT_0_1;
- params.timeMode = mode;
- params.overlapPct = 100;
- const Trace trace = buildTwoFragmentTrace(kb, kFragA, kFragB, params);
-
- const TrackStats t0 = analyzeTrack(kb, trace, 0);
- const TrackStats t1 = analyzeTrack(kb, trace, 1);
- EXPECT_TRUE(t0.used);
- EXPECT_TRUE(t1.used);
- if (baselineT0 < 0) {
- baselineT0 = t0.sampledSize;
- baselineT1 = t1.sampledSize;
- }
- // Membership is decided purely by pointer id: shifting/overlapping the clocks cannot
- // move a point from one track to the other.
- EXPECT_EQ(baselineT0, t0.sampledSize) << "time mode changed track-0 membership";
- EXPECT_EQ(baselineT1, t1.sampledSize) << "time mode changed track-1 membership";
- }
-}
-
-// =============================================================================
-// 5. Sweep: prints the parameter table so the knobs can be explored by hand.
-// Run with: ctest --test-dir -R TwoPointerSweep --output-on-failure
-// =============================================================================
-
-TEST(TwoPointerSweep, PrintTrackTable) {
- const Qwerty kb;
- struct Row { const char *name; TrackParams::PointerMode pm; TrackParams::TimeMode tm; int overlap; };
- const Row rows[] = {
- {"today (all id 0, monotonic)", TrackParams::ALL_ZERO, TrackParams::GLOBAL_MONOTONIC, 0},
- {"today (all id 0, restart) ", TrackParams::ALL_ZERO, TrackParams::PER_POINTER_RESTART, 0},
- {"split (0/1, monotonic) ", TrackParams::SPLIT_0_1, TrackParams::GLOBAL_MONOTONIC, 0},
- {"split (0/1, restart) ", TrackParams::SPLIT_0_1, TrackParams::PER_POINTER_RESTART, 0},
- {"split (0/1, overlap 50%) ", TrackParams::SPLIT_0_1, TrackParams::OVERLAPPED, 50},
- {"split (0/1, overlap 100%) ", TrackParams::SPLIT_0_1, TrackParams::OVERLAPPED, 100},
- {"split (1/0 reversed) ", TrackParams::SPLIT_1_0, TrackParams::GLOBAL_MONOTONIC, 0},
- {"all id 1 ", TrackParams::ALL_ONE, TrackParams::GLOBAL_MONOTONIC, 0},
- {"all id 2 ", TrackParams::ALL_TWO, TrackParams::GLOBAL_MONOTONIC, 0},
- };
- std::printf("\n%-30s | t0.used t0.n [raw range] minSpeed | t1.used t1.n [raw range] minSpeed\n",
- "config");
- std::printf("%s\n", std::string(120, '-').c_str());
- for (const Row &r : rows) {
- TrackParams params;
- params.pointerMode = r.pm;
- params.timeMode = r.tm;
- params.overlapPct = r.overlap;
- const Trace trace = buildTwoFragmentTrace(kb, kFragA, kFragB, params);
- const TrackStats t0 = analyzeTrack(kb, trace, 0);
- const TrackStats t1 = analyzeTrack(kb, trace, 1);
- std::printf("%-30s | %d %3d [%3d..%3d] %8.3f | %d %3d [%3d..%3d] %8.3f\n",
- r.name, t0.used ? 1 : 0, t0.sampledSize, t0.minRawIndex, t0.maxRawIndex,
- t0.minSpeedRate, t1.used ? 1 : 0, t1.sampledSize, t1.minRawIndex, t1.maxRawIndex,
- t1.minSpeedRate);
- }
- std::printf("\n(raw index range shows which raw samples reached each track; fragment A is "
- "raw [0..%d))\n\n", buildTwoFragmentTrace(kb, kFragA, kFragB, TrackParams()).fragmentASize);
- SUCCEED();
-}
-
-} // namespace
-} // namespace replay
-} // namespace latinime
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 76f9fe7a5..b99f9c5fb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -396,21 +396,6 @@
Delete whole word
Improve two-thumb recognition (experimental)
Adds synthetic hints for the recognizer when both thumbs are used. It may help two-thumb gestures, but can hurt accuracy if the hand split is wrong.
-
- Not active at this spacing mode
- These settings only apply while a word is being built from more than one swipe. That happens when the spacing mode above is set to manual spacing, or when the autospace delay is above zero. At the default spacing mode they have no effect.
-
- Needs the gesture library
- These settings shape the touch points sent to the native gesture decoder. The built-in fallback engine scores a single trail and ignores which thumb drew it, so they would make recognition worse rather than better. Load a gesture library under Gesture typing to enable them.
- Joining word parts
- How an earlier part of the word is fed to the recognizer together with the part you are swiping now.
- One joined trail
- Two separate thumb tracks (experimental)
- Word-part trail speed Spacing between the replayed points of the earlier word part. Lower is a faster imagined swipe.
- Pause before the new part
- Imagined pause between the earlier word part and the part you are swiping now.
- Redraw earlier word parts cleanly
- Replaces the earlier part of the 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.
Keep insight across word parts
Keep the trail visible across the parts of one word, clearing when the next word starts. Turn off to clear at each new swipe.
diff --git a/app/src/test/java/helium314/keyboard/keyboard/internal/BatchInputArbiterTest.kt b/app/src/test/java/helium314/keyboard/keyboard/internal/BatchInputArbiterTest.kt
new file mode 100644
index 000000000..cec52f48c
--- /dev/null
+++ b/app/src/test/java/helium314/keyboard/keyboard/internal/BatchInputArbiterTest.kt
@@ -0,0 +1,65 @@
+package helium314.keyboard.keyboard.internal
+
+import helium314.keyboard.latin.common.InputPointers
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class BatchInputArbiterTest {
+
+ @Test
+ fun `raw pointer one anchors track zero through update and end`() {
+ val arbiter = BatchInputArbiter(1, GestureStrokeRecognitionParams.DEFAULT)
+ arbiter.setKeyboardGeometry(100, 500)
+ val points = BatchInputArbiter::class.java.getDeclaredField("mRecognitionPoints").run {
+ isAccessible = true
+ get(arbiter) as GestureStrokeRecognitionPoints
+ }
+ val listener = RecordingListener()
+
+ points.addEventPoint(0, 0, 0, true)
+ points.addEventPoint(200, 0, 10, true)
+ points.addEventPoint(400, 0, 40, true)
+ assertTrue(arbiter.mayStartBatchInput(listener))
+
+ points.addEventPoint(420, 0, 140, true)
+ arbiter.updateBatchInput(140, listener)
+
+ assertEquals(4, listener.updatedSize)
+ assertContentEquals(intArrayOf(0, 0, 0, 0), listener.updatedPointerIds)
+
+ points.addEventPoint(440, 0, 200, true)
+ assertTrue(arbiter.mayEndBatchInput(200, 1, listener))
+
+ assertEquals(5, listener.endedSize)
+ assertContentEquals(intArrayOf(0, 0, 0, 0, 0), listener.endedPointerIds)
+ }
+
+ private class RecordingListener : BatchInputArbiter.BatchInputArbiterListener {
+ var updatedSize = 0
+ var updatedPointerIds = intArrayOf()
+ var endedSize = 0
+ var endedPointerIds = intArrayOf()
+
+ override fun onStartBatchInput() = Unit
+
+ override fun onUpdateBatchInput(
+ aggregatedPointers: InputPointers,
+ moveEventTime: Long,
+ ) {
+ updatedSize = aggregatedPointers.pointerSize
+ updatedPointerIds = aggregatedPointers.pointerIds.copyOf(updatedSize)
+ }
+
+ override fun onStartUpdateBatchInputTimer() = Unit
+
+ override fun onEndBatchInput(
+ aggregatedPointers: InputPointers,
+ upEventTime: Long,
+ ) {
+ endedSize = aggregatedPointers.pointerSize
+ endedPointerIds = aggregatedPointers.pointerIds.copyOf(endedSize)
+ }
+ }
+}
diff --git a/app/src/test/java/helium314/keyboard/keyboard/internal/PointerIdNormalizerTest.kt b/app/src/test/java/helium314/keyboard/keyboard/internal/PointerIdNormalizerTest.kt
index fd9575f7c..356b7d7ee 100644
--- a/app/src/test/java/helium314/keyboard/keyboard/internal/PointerIdNormalizerTest.kt
+++ b/app/src/test/java/helium314/keyboard/keyboard/internal/PointerIdNormalizerTest.kt
@@ -9,8 +9,7 @@ import kotlin.test.assertNotEquals
*
* The native gesture decoder keeps exactly two per-pointer tracks and seeds track *i* with pointer
* id *i* (`jni/src/defines.h` MAX_POINTER_COUNT_G, `dic_traverse_session.cpp`). Two failure modes
- * follow, both measured against the real AOSP preprocessing in
- * `jni/tests/replay/two_pointer_track_test.cpp`:
+ * follow from that in-tree preprocessing:
*
* - no point carrying id 0 ⇒ track 0 unused ⇒ `Suggest::initializeSearch` returns early ⇒
* **zero suggestions**;
diff --git a/app/src/test/java/helium314/keyboard/latin/WordComposerTest.java b/app/src/test/java/helium314/keyboard/latin/WordComposerTest.java
index e9c867895..6c7a9d3ef 100644
--- a/app/src/test/java/helium314/keyboard/latin/WordComposerTest.java
+++ b/app/src/test/java/helium314/keyboard/latin/WordComposerTest.java
@@ -3,6 +3,7 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
+import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertFalse;
@@ -87,14 +88,14 @@ public void testExtendBatchInputBaseMergesAndRetimes() {
// Prior fragment trail (e.g. the tapped "he" key centers) — 2 points.
final InputPointers base = new InputPointers(16);
- base.addPointer(10, 20, 0, 0);
- base.addPointer(30, 40, 0, 0);
+ base.addPointer(10, 20, 4, 0);
+ base.addPointer(30, 40, 5, 0);
// The new gesture's raw pointers — 3 points with real, increasing times.
final InputPointers batch = new InputPointers(16);
- batch.addPointer(100, 200, 0, 1000);
- batch.addPointer(110, 210, 0, 1025);
- batch.addPointer(120, 220, 0, 1050);
+ batch.addPointer(100, 200, 7, 1000);
+ batch.addPointer(110, 210, 8, 1025);
+ batch.addPointer(120, 220, 9, 1050);
wordComposer.setExtendBatchInputBase(base);
assertTrue(wordComposer.isExtendBatchInputBaseSet());
@@ -105,25 +106,19 @@ public void testExtendBatchInputBaseMergesAndRetimes() {
// base (2) + new gesture (3) = 5, fed to the recognizer as one continuous stroke.
assertEquals(5, merged.getPointerSize());
- final int[] xs = merged.getXCoordinates();
- final int[] times = merged.getTimes();
-
- // Base coordinates come first, in order, untouched.
- assertEquals(10, xs[0]);
- assertEquals(30, xs[1]);
- // New gesture coordinates follow, untouched.
- assertEquals(100, xs[2]);
- assertEquals(110, xs[3]);
- assertEquals(120, xs[4]);
-
- // The new gesture's ORIGINAL times are preserved verbatim (appendAll).
- assertEquals(1000, times[2]);
- assertEquals(1025, times[3]);
- assertEquals(1050, times[4]);
+ assertArrayEquals(new int[] { 10, 30, 100, 110, 120 },
+ java.util.Arrays.copyOf(merged.getXCoordinates(), 5));
+ assertArrayEquals(new int[] { 20, 40, 200, 210, 220 },
+ java.util.Arrays.copyOf(merged.getYCoordinates(), 5));
+ assertArrayEquals(new int[] { 915, 940, 1000, 1025, 1050 },
+ java.util.Arrays.copyOf(merged.getTimes(), 5));
+ assertArrayEquals(new int[] { 0, 0, 0, 0, 0 },
+ java.util.Arrays.copyOf(merged.getPointerIds(), 5));
// The re-timed base sits strictly BEFORE the new gesture, and the whole stream is
// monotonically increasing — that's what makes the recognizer treat it as a single
// stroke rather than two distinct ones.
+ final int[] times = merged.getTimes();
for (int i = 1; i < merged.getPointerSize(); i++) {
assertTrue("times must strictly increase at index " + i, times[i] > times[i - 1]);
}
diff --git a/app/src/test/java/helium314/keyboard/latin/gesture/IdealPrefixTrailBuilderTest.kt b/app/src/test/java/helium314/keyboard/latin/gesture/IdealPrefixTrailBuilderTest.kt
deleted file mode 100644
index e34884586..000000000
--- a/app/src/test/java/helium314/keyboard/latin/gesture/IdealPrefixTrailBuilderTest.kt
+++ /dev/null
@@ -1,146 +0,0 @@
-package helium314.keyboard.latin.gesture
-
-import helium314.keyboard.ShadowInputMethodManager2
-import helium314.keyboard.ShadowProximityInfo
-import helium314.keyboard.keyboard.Key
-import helium314.keyboard.keyboard.Keyboard
-import helium314.keyboard.keyboard.KeyboardId
-import helium314.keyboard.keyboard.KeyboardLayoutSet
-import helium314.keyboard.keyboard.internal.KeyboardParams
-import helium314.keyboard.latin.common.InputPointers
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import kotlin.test.assertNull
-import kotlin.test.assertTrue
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
-
-/**
- * Covers the synthetic prefix trail, including the tap → micro-stroke promotion.
- *
- * Recognition *quality* is not testable here (there is no gesture policy in this tree), so these
- * assert the geometry contract only: a tap becomes a stroke with a vertex, multi-letter prefixes
- * are densified, and unbuildable inputs return null so the caller falls back to the raw trail.
- */
-@RunWith(RobolectricTestRunner::class)
-@Config(shadows = [ShadowInputMethodManager2::class, ShadowProximityInfo::class])
-class IdealPrefixTrailBuilderTest {
-
- /** A single row of 100x100 letter keys, laid out left to right. */
- private fun keyboardFor(letters: String): Keyboard {
- val params = KeyboardParams().apply {
- mId = KeyboardLayoutSet.getFakeKeyboardId(KeyboardId.ELEMENT_ALPHABET)
- mOccupiedWidth = letters.length * 100
- mOccupiedHeight = 100
- mBaseWidth = mOccupiedWidth
- mBaseHeight = mOccupiedHeight
- mMostCommonKeyWidth = 100
- mMostCommonKeyHeight = 100
- GRID_WIDTH = letters.length
- GRID_HEIGHT = 1
- }
- letters.forEachIndexed { index, letter ->
- params.onAddKey(Key(
- letter.toString(), null, letter.code, null, null,
- 0, Key.BACKGROUND_TYPE_NORMAL,
- index * 100, 0, 100, 100, 0, 0,
- ))
- }
- return Keyboard(params)
- }
-
- private fun keyboard() = keyboardFor("techsplo")
-
- private fun InputPointers.xs() = xCoordinates.take(pointerSize)
- private fun InputPointers.ys() = yCoordinates.take(pointerSize)
- private fun InputPointers.ids() = pointerIds.take(pointerSize)
-
- @Test
- fun `null or empty input yields null so the caller keeps the raw trail`() {
- val kb = keyboard()
- assertNull(IdealPrefixTrailBuilder.build(null, kb))
- assertNull(IdealPrefixTrailBuilder.build("", kb))
- assertNull(IdealPrefixTrailBuilder.build("hello", null))
- }
-
- @Test
- fun `a word with no mappable letters yields null`() {
- assertNull(IdealPrefixTrailBuilder.build("123", keyboard()))
- }
-
- /** The tap-promotion claim: one letter must come back as a stroke, not a point. */
- @Test
- fun `a single letter prefix becomes an out-and-back micro-stroke`() {
- val trail = assertNotNull(IdealPrefixTrailBuilder.build("s", keyboard()))
-
- assertEquals(4, trail.pointerSize, "a tap must be promoted to a 4-point micro-stroke")
- val xs = trail.xs()
- val ys = trail.ys()
- // Out and back around the key centre: left, centre, right, centre.
- assertEquals(xs[1], xs[3], "the micro-stroke must return to the key centre")
- assertTrue(xs[0] < xs[1], "first point must sit left of centre")
- assertTrue(xs[2] > xs[1], "third point must sit right of centre")
- assertTrue(ys.all { it == ys[0] }, "the micro-arc stays on one row")
- // A real vertex, not a degenerate zero-length wiggle.
- assertTrue(xs[2] - xs[0] > 1, "the micro-stroke must have non-trivial extent")
- }
-
- @Test
- fun `a multi letter prefix is densified beyond one point per key`() {
- val trail = assertNotNull(IdealPrefixTrailBuilder.build("tech", keyboard()))
- assertTrue(trail.pointerSize > 4,
- "expected interpolated samples between key centres, got ${trail.pointerSize}")
- }
-
- @Test
- fun `every synthesised point is on the base track`() {
- for (word in listOf("s", "tech", "hello")) {
- val trail = assertNotNull(IdealPrefixTrailBuilder.build(word, keyboard()))
- assertTrue(trail.ids().all { it == StrokeAligner.BASE_POINTER_ID },
- "$word: the prefix trail must stay on decoder track 0")
- }
- }
-
- @Test
- fun `punctuation is skipped but an unmappable letter forces a fallback`() {
- // Apostrophes legitimately have no place on the trail.
- val withApostrophe = assertNotNull(IdealPrefixTrailBuilder.build("to'p", keyboard()))
- val without = assertNotNull(IdealPrefixTrailBuilder.build("top", keyboard()))
- assertEquals(without.pointerSize, withApostrophe.pointerSize)
- assertEquals(without.xs(), withApostrophe.xs())
-
- // A letter that isn't on this keyboard would leave a hole in the synthetic path, which is
- // worse than the raw trail — so the builder bails out and the caller falls back.
- assertNull(IdealPrefixTrailBuilder.build("tzch", keyboard()))
- assertNull(IdealPrefixTrailBuilder.build("téch", keyboard()))
- }
-
- @Test
- fun `case does not change the produced geometry`() {
- val lower = assertNotNull(IdealPrefixTrailBuilder.build("tech", keyboard()))
- val upper = assertNotNull(IdealPrefixTrailBuilder.build("TECH", keyboard()))
- assertEquals(lower.xs(), upper.xs())
- assertEquals(lower.ys(), upper.ys())
- }
-
- /** The whole point of the builder: feed StrokeAligner a stroke-like base. */
- @Test
- fun `the synthesised tap trail survives a StrokeAligner merge as a real stroke`() {
- val trail = assertNotNull(IdealPrefixTrailBuilder.build("s", keyboard()))
- val current = InputPointers(8).apply {
- addPointer(500, 100, 0, 1000)
- addPointer(520, 105, 0, 1025)
- }
- val out = InputPointers(16)
- StrokeAligner.merge(out, trail, current,
- StrokeAligner.Params(StrokeAligner.Mode.DUAL_POINTER, 25, 60))
-
- assertEquals(6, out.pointerSize)
- assertEquals(listOf(0, 0, 0, 0, 1, 1), out.ids())
- out.times.take(out.pointerSize).zipWithNext().forEach { (a, b) ->
- assertTrue(b >= a, "merged trail must stay monotonic in time")
- }
- }
-}
diff --git a/app/src/test/java/helium314/keyboard/latin/gesture/StrokeAlignerTest.kt b/app/src/test/java/helium314/keyboard/latin/gesture/StrokeAlignerTest.kt
deleted file mode 100644
index 03192fc4d..000000000
--- a/app/src/test/java/helium314/keyboard/latin/gesture/StrokeAlignerTest.kt
+++ /dev/null
@@ -1,276 +0,0 @@
-package helium314.keyboard.latin.gesture
-
-import helium314.keyboard.latin.common.InputPointers
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-/**
- * Pins [StrokeAligner]'s merge contract.
- *
- * The invariants asserted here are not stylistic — each maps to a measured property of the native
- * decoder (`jni/tests/replay/two_pointer_track_test.cpp`, `docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md`):
- *
- * - track 0 must be non-empty or `Suggest::initializeSearch` returns zero suggestions;
- * - only pointer ids 0 and 1 reach a decoder track at all;
- * - timestamps must be globally monotonic, because the decoder's speed/beeline features walk the
- * raw arrays across the track boundary and a decreasing timestamp yields a negative duration.
- */
-class StrokeAlignerTest {
-
- private fun pointers(vararg triples: Triple, id: Int = 0) =
- InputPointers(16).apply {
- triples.forEach { (x, y, t) -> addPointer(x, y, id, t) }
- }
-
- private fun base() = pointers(
- Triple(10, 10, 0),
- Triple(20, 12, 0),
- Triple(30, 14, 0), // tap-sourced coords carry a time=0 sentinel
- )
-
- private fun current() = pointers(
- Triple(100, 50, 1000),
- Triple(120, 55, 1025),
- Triple(140, 60, 1050),
- )
-
- private fun InputPointers.idsList() = pointerIds.take(pointerSize)
- private fun InputPointers.timesList() = times.take(pointerSize)
- private fun InputPointers.xsList() = xCoordinates.take(pointerSize)
-
- // ---- shared invariants -------------------------------------------------
-
- @Test
- fun `connector mode keeps every point on track zero`() {
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), current(),
- StrokeAligner.Params(StrokeAligner.Mode.CONNECTOR, 25, 60))
-
- assertEquals(6, out.pointerSize)
- assertTrue(out.idsList().all { it == 0 }, "connector mode must not split tracks")
- }
-
- @Test
- fun `dual pointer mode puts the base on track zero and the new stroke on track one`() {
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), current(),
- StrokeAligner.Params(StrokeAligner.Mode.DUAL_POINTER, 25, 60))
-
- assertEquals(listOf(0, 0, 0, 1, 1, 1), out.idsList())
- }
-
- @Test
- fun `no mode ever emits a pointer id the decoder would discard`() {
- for (mode in StrokeAligner.Mode.entries) {
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), current(), StrokeAligner.Params(mode, 25, 60))
- assertTrue(out.idsList().all { it == 0 || it == 1 },
- "$mode emitted an id outside [0,1]; those reach no decoder track")
- }
- }
-
- @Test
- fun `track zero is always populated so the search does not bail out`() {
- for (mode in StrokeAligner.Mode.entries) {
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), current(), StrokeAligner.Params(mode, 25, 60))
- assertTrue(out.idsList().contains(0),
- "$mode left track 0 empty; Suggest::initializeSearch would return no suggestions")
- }
- }
-
- @Test
- fun `timestamps are globally monotonic in every mode`() {
- for (mode in StrokeAligner.Mode.entries) {
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), current(), StrokeAligner.Params(mode, 25, 60))
- val times = out.timesList()
- times.zipWithNext().forEach { (a, b) ->
- assertTrue(b >= a, "$mode produced a decreasing timestamp ($a -> $b); " +
- "the decoder's speed features would compute a negative duration")
- }
- }
- }
-
- @Test
- fun `base timestamps are re-synthesised to land just before the new stroke`() {
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), current(),
- StrokeAligner.Params(StrokeAligner.Mode.CONNECTOR, 25, 60))
-
- // current starts at 1000, gap 60 => base ends at 940, interval 25 over 3 points.
- assertEquals(listOf(890, 915, 940, 1000, 1025, 1050), out.timesList())
- }
-
- @Test
- fun `geometry is preserved and ordered base-then-current`() {
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), current(),
- StrokeAligner.Params(StrokeAligner.Mode.DUAL_POINTER, 25, 60))
-
- assertEquals(listOf(10, 20, 30, 100, 120, 140), out.xsList())
- }
-
- // ---- knobs -------------------------------------------------------------
-
- @Test
- fun `interval and gap knobs move the base timeline in dual pointer mode`() {
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), current(),
- StrokeAligner.Params(StrokeAligner.Mode.DUAL_POINTER, 10, 100))
-
- // gap 100 => base ends at 900; interval 10 over 3 points.
- assertEquals(listOf(880, 890, 900, 1000, 1025, 1050), out.timesList())
- }
-
- @Test
- fun `non-positive knobs are clamped so the base cannot go non-monotonic`() {
- val params = StrokeAligner.Params(StrokeAligner.Mode.CONNECTOR, 0, -5)
- assertTrue(params.basePointIntervalMs >= 1)
- assertTrue(params.gapBeforeNewMs >= 1)
-
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), current(), params)
- out.timesList().zipWithNext().forEach { (a, b) -> assertTrue(b >= a) }
- }
-
- @Test
- fun `defaults reproduce the historical connector behaviour`() {
- val defaults = StrokeAligner.Params.defaults()
- assertEquals(StrokeAligner.Mode.CONNECTOR, defaults.mode)
- assertEquals(25, defaults.basePointIntervalMs)
- assertEquals(60, defaults.gapBeforeNewMs)
- }
-
- @Test
- fun `unknown or missing pref values fall back to connector`() {
- assertEquals(StrokeAligner.Mode.CONNECTOR, StrokeAligner.Mode.fromPrefValue("connector"))
- assertEquals(StrokeAligner.Mode.CONNECTOR, StrokeAligner.Mode.fromPrefValue("nonsense"))
- assertEquals(StrokeAligner.Mode.CONNECTOR, StrokeAligner.Mode.fromPrefValue(null))
- assertEquals(StrokeAligner.Mode.DUAL_POINTER,
- StrokeAligner.Mode.fromPrefValue("dual_pointer"))
- }
-
- // ---- degenerate inputs -------------------------------------------------
-
- @Test
- fun `mode changes ids only, never geometry or timing`() {
- // The Java SwipeGestureEngine fallback (used when the native lib is absent, e.g. the
- // offlinelite flavor) flattens all points into one path and ignores pointer ids, so
- // DUAL_POINTER must be invisible to it.
- val connector = InputPointers(16)
- val dual = InputPointers(16)
- StrokeAligner.merge(connector, base(), current(),
- StrokeAligner.Params(StrokeAligner.Mode.CONNECTOR, 25, 60))
- StrokeAligner.merge(dual, base(), current(),
- StrokeAligner.Params(StrokeAligner.Mode.DUAL_POINTER, 25, 60))
-
- assertEquals(connector.pointerSize, dual.pointerSize)
- assertEquals(connector.xsList(), dual.xsList())
- assertEquals(connector.ys().take(connector.pointerSize), dual.ys().take(dual.pointerSize))
- assertEquals(connector.timesList(), dual.timesList())
- assertTrue(connector.idsList() != dual.idsList(), "only the ids should differ")
- }
-
- private fun InputPointers.ys() = yCoordinates.toList()
-
- @Test
- fun `connector mode ignores the timing knobs so it always means historical behaviour`() {
- // The sliders are only shown for DUAL_POINTER; tuning them there and switching back must
- // not silently change what "one joined trail" does.
- val tuned = InputPointers(16)
- StrokeAligner.merge(tuned, base(), current(),
- StrokeAligner.Params(StrokeAligner.Mode.CONNECTOR, 5, 200))
-
- assertEquals(listOf(890, 915, 940, 1000, 1025, 1050), tuned.timesList())
- }
-
- @Test
- fun `dual pointer preserves an already multi-pointer current stroke`() {
- // A genuinely simultaneous two-thumb stroke already occupies both decoder tracks;
- // flattening it onto track 1 would destroy that structure.
- val simultaneous = InputPointers(8).apply {
- addPointer(100, 50, 0, 1000)
- addPointer(300, 50, 1, 1005)
- addPointer(120, 55, 0, 1025)
- addPointer(320, 55, 1, 1030)
- }
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), simultaneous,
- StrokeAligner.Params(StrokeAligner.Mode.DUAL_POINTER, 25, 60))
-
- assertEquals(listOf(0, 0, 0, 0, 1, 0, 1), out.idsList())
- assertTrue(out.idsList().all { it == 0 || it == 1 })
- out.timesList().zipWithNext().forEach { (a, b) -> assertTrue(b >= a) }
- }
-
- @Test
- fun `an empty base copies the current stroke through untouched`() {
- // This is the ordinary single-swipe path, including genuinely simultaneous two-thumb
- // input, whose real MotionEvent pointer ids must survive unchanged.
- val simultaneous = InputPointers(16).apply {
- addPointer(1, 1, 0, 10)
- addPointer(2, 2, 1, 12)
- }
- val out = InputPointers(16)
- StrokeAligner.merge(out, InputPointers(4), simultaneous,
- StrokeAligner.Params(StrokeAligner.Mode.DUAL_POINTER, 25, 60))
-
- assertEquals(2, out.pointerSize)
- assertEquals(listOf(0, 1), out.idsList())
- assertEquals(listOf(10, 12), out.timesList())
- }
-
- @Test
- fun `an empty current stroke yields just the base`() {
- val out = InputPointers(16)
- StrokeAligner.merge(out, base(), InputPointers(4),
- StrokeAligner.Params(StrokeAligner.Mode.DUAL_POINTER, 25, 60))
-
- assertEquals(3, out.pointerSize)
- assertTrue(out.idsList().contains(0))
- }
-
- @Test
- fun `merging into a non-empty output resets it first`() {
- val out = InputPointers(16).apply { addPointer(999, 999, 1, 999) }
- StrokeAligner.merge(out, base(), current(), StrokeAligner.Params.defaults())
-
- assertEquals(6, out.pointerSize, "stale points must not survive the merge")
- assertEquals(10, out.xsList().first())
- }
-
- @Test
- fun `null params behave as defaults`() {
- val withNull = InputPointers(16)
- val withDefaults = InputPointers(16)
- StrokeAligner.merge(withNull, base(), current(), null)
- StrokeAligner.merge(withDefaults, base(), current(), StrokeAligner.Params.defaults())
-
- assertEquals(withDefaults.timesList(), withNull.timesList())
- assertEquals(withDefaults.idsList(), withNull.idsList())
- }
-
- /**
- * Invariant 4: a given raw index must not change track as the stroke grows, because
- * `checkAndReturnIsContinuousSuggestionPossible` compares x/y/time but not pointer ids.
- */
- @Test
- fun `pointer ids for existing points are stable as the stroke grows`() {
- val params = StrokeAligner.Params(StrokeAligner.Mode.DUAL_POINTER, 25, 60)
- val first = InputPointers(16)
- StrokeAligner.merge(first, base(), current(), params)
-
- val grown = current().apply { addPointer(160, 65, 0, 1075) }
- val second = InputPointers(16)
- StrokeAligner.merge(second, base(), grown, params)
-
- val firstIds = first.idsList()
- val secondIds = second.idsList()
- assertEquals(firstIds, secondIds.take(firstIds.size),
- "an already-seen point changed decoder track mid-gesture")
- assertEquals(first.timesList(), second.timesList().take(firstIds.size),
- "an already-seen point was re-timed mid-gesture")
- }
-}
diff --git a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt
index 7fd6b70a5..38db92813 100644
--- a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt
+++ b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt
@@ -3,7 +3,6 @@ package helium314.keyboard.settings
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import helium314.keyboard.latin.R
-import helium314.keyboard.latin.settings.Defaults
import helium314.keyboard.latin.settings.Settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
@@ -89,31 +88,8 @@ class SettingsContainerTest {
}
@Test
- fun strokeAlignmentSettingsAreRegistered() {
- // All four are conditionally rendered on the Two-Thumb screen, so without an entry in the
- // screen's Setting{} list they would silently vanish from settings search.
- assertEquals(Settings.PREF_STROKE_ALIGN_MODE,
- container[Settings.PREF_STROKE_ALIGN_MODE]?.key)
- assertEquals(Settings.PREF_STROKE_IDEAL_PREFIX,
- container[Settings.PREF_STROKE_IDEAL_PREFIX]?.key)
- assertEquals(Settings.PREF_STROKE_ALIGN_INTERVAL_MS,
- container[Settings.PREF_STROKE_ALIGN_INTERVAL_MS]?.key)
- assertEquals(Settings.PREF_STROKE_ALIGN_GAP_MS,
- container[Settings.PREF_STROKE_ALIGN_GAP_MS]?.key)
- }
-
- @Test
- fun strokeAlignmentDefaultsPreserveHistoricalBehaviour() {
- // The experimental modes must stay opt-in: DUAL_POINTER and the synthetic prefix trail
- // both change what the recognizer sees.
- assertEquals("connector", Defaults.PREF_STROKE_ALIGN_MODE)
- assertEquals(false, Defaults.PREF_STROKE_IDEAL_PREFIX)
- assertEquals(25, Defaults.PREF_STROKE_ALIGN_INTERVAL_MS)
- assertEquals(60, Defaults.PREF_STROKE_ALIGN_GAP_MS)
- }
-
- @Test
- fun twoThumbFragmentBackspaceLabelMatchesBehavior() { val context = ApplicationProvider.getApplicationContext()
+ fun twoThumbFragmentBackspaceLabelMatchesBehavior() {
+ val context = ApplicationProvider.getApplicationContext()
assertEquals("Delete last fragment", context.getString(R.string.two_thumb_backspace_fragment))
}
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index f47c886af..5f22ee100 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -1,4 +1,4 @@
-# LeanTypeDual — Session Handoff (2026-08-06)
+# LeanTypeDual — Session Handoff (updated 2026-09-03)
This document lets a new agent/session resume without re-deriving context. It records
**what shipped, exactly where everything sits, what is still open, and the traps that cost
@@ -7,9 +7,9 @@ the session — no assumptions.
Read alongside `AGENTS.md` (repo conventions, which remain authoritative).
-> **Amended 2026-08-20:** §5 (v0.2.0 published, release-blocker removed), §11 (worktree paths
-> corrected after the tree moved to `C:/Users/mahle/programming/`, plus the worktree cleanup),
-> and §12 (current open items).
+> **Current-state refresh 2026-09-03:** §1, §5, §6, §11 and §12 now reflect v0.3.0,
+> LeanBitLab v4.1.8, the Shift fix, the fork-invariant gates, and removal of the falsified
+> gesture experiments. Sections 2, 3 and 7 remain historical context.
---
@@ -17,20 +17,20 @@ Read alongside `AGENTS.md` (repo conventions, which remain authoritative).
| Thing | State |
|---|---|
-| `main` | `caed9f65a` — "Release LeanTypeDual 0.2.0 (#130)" |
-| `dev` | `6ac372de3` — "docs: session handoff (#132)" |
-| Current version | `0.2.0` / versionCode `4200` on both `main` and `dev` |
+| `main` | `b90d79de1` — released LeanTypeDual 0.3.0 |
+| `dev` | `deaa782dd` — Shift fix (#150), after LeanBitLab v4.1.8 (#149); this cleanup lands next |
+| Current version | `0.3.0` / versionCode `4300` on both `main` and `dev` |
| Tag `v0.1.0` | Pushed **and published** with 4 signed APKs |
-| Tag `v0.2.0` | **Published and latest** with 4 signed APKs |
-| Upstream integrated | LeanBitLab/LeanType **v4.0.8** (`dec87806`), covering v4.0.3–v4.0.8 |
-| Phone (SM-S936B) | `com.asafmah.leantypedual` = signed **0.1.0/4100**; `…debug` = **0.2.0/4200** |
+| Tag `v0.3.0` | **Published and latest** with 4 signed APKs, all verified after download |
+| Upstream integrated | LeanBitLab/LeanType **v4.1.8** (`cbfaf21a`), covering v4.1.3–v4.1.8 |
+| Phone (SM-S936B) | Last verified with signed **0.3.0/4300**, debug, and EXP packages; wireless ADB is currently unavailable |
| Tablet | Never verified — still outstanding, low risk |
-| Open PRs | **#106** (`issue37-slide-target-actions`), **#134** (backspace paragraph merge), **#136** (two-thumb native second pointer track), **#137** (upstream v4.1.2) |
+| Open PRs | After this cleanup lands, only **#106** (`issue37-slide-target-actions`, unrelated/pre-existing) |
-**No release work is outstanding** — v0.2.0 shipped signed on 2026-08-20. Open items are now
-device verification of #134 and #137. §7's two inherited upstream defects were fixed by v4.1.2
-and their guards removed; the emoji accelerated-delete bug is reported as
-`LeanBitLab/LeanType#423`. See §12.
+**No release work is outstanding.** Open work is device verification of the Shift fix and
+v4.1.8 merge, issue #106, and deliberate triage of the backed-up old branches/worktrees.
+The two-track/ideal-prefix experiment was falsified on device and is removed by this cleanup;
+the proven pointer-id normalization remains. See §12.
---
@@ -61,6 +61,19 @@ fixes, and many emoji/clipboard layout fixes.
- Signed artifacts were delayed by a runner outage (§5) and **published on 2026-08-20**:
four signed APKs, release marked latest, all four verified after download.
+### 2.4 Released 0.3.0 and stabilized dev
+- Released `0.3.0`/`4300` from `main` (`b90d79de1`) with four verified signed APKs.
+- Merged LeanBitLab v4.1.8 in #149 with real upstream ancestry preserved. The audit kept the
+ fork's four flavors, bundled offline llama/GGUF and dictionaries, offlinelite no-AI behavior,
+ and network-free offline tiers.
+- Added source/config and packaged-APK invariant gates in #148. Release CI now verifies the
+ effective app IDs, minSdk values, INTERNET permissions, dictionary contents, exact four-APK
+ set, and low-API signature coverage.
+- Fixed fast Shift double-tap/Caps Lock in #150. The fix replaces an arbitrary 100 ms minimum
+ with the real invariant: two presses must have an intervening release boundary.
+- Removed the falsified DUAL_POINTER, ideal-prefix and re-timing experiment machinery while
+ retaining the proven pointer-id normalization and side-by-side EXP packaging (#147).
+
---
## 3. Release ordering rationale (do not "fix" this)
@@ -72,8 +85,8 @@ fixes, and many emoji/clipboard layout fixes.
- Fork `versionName` is independent of upstream's; Android upgrade continuity depends on
identical `applicationId` + signing key + monotonic `versionCode`, not the name.
-An adversarial cross-model review confirmed this ordering as SOUND. Any future release
-containing the upstream layer must keep `versionCode > 4100` (0.2.0 uses `4200`).
+An adversarial cross-model review confirmed this ordering as SOUND. Keep versionCode monotonic;
+the latest release, 0.3.0, uses `4300`, so the next release must be above it.
---
@@ -82,26 +95,30 @@ containing the upstream layer must keep `versionCode > 4100` (0.2.0 uses `4200`)
| Invariant | Expected |
|---|---|
| `applicationId` | `com.asafmah.leantypedual` (+ `.offline`, `.offlinelite`, `.debug`) |
-| Version | Fork's own (`0.2.0`/`4200`) — **never** take upstream's `4.0.x`/`400x` |
+| Version | Fork's own (`0.3.0`/`4300` currently) — **never** take upstream's `4.x`/`410x` |
+| Flavors | Keep `standard`, `standardfull`, bundled-llama `offline` (minSdk 26), and no-AI `offlinelite` (minSdk 21). Upstream v4.1.7 deletes `offlinelite`; reject that product change |
| `INTERNET` permission | Only `app/src/standard/` and `app/src/standardfull/` manifests. `offline`/`offlinelite` have **no manifest at all** and inherit the network-free main one |
+| Offline assets | `offline`/`offlinelite` bundle dictionaries; standard/full exclude them. Keep `offlineImplementation("io.github.ljcamargo:llamacpp-kotlin:0.4.0")` |
+| Floating overlay | `SYSTEM_ALERT_WINDOW` in main is accepted for floating mode, but access remains user-granted via system settings and normal docked operation works without it |
| Java fallback gesture engine | `SwipeGestureEngine.initialize(this)` in `LatinIME.onCreate`; fallback/native selector in `GestureTypingScreen` + `WelcomeWizard` |
| Two-thumb typing | Own screen + settings; **must** be registered in the `modules` list in `SettingsContainer.kt` (upstream's new registry drives settings search) |
| AndroidX Startup | Exactly **one** `InitializationProvider` in the main manifest, containing all initializer removals |
| Badges | `docs/badges/*.svg` — keep ours, never upstream's generated ones |
-Quick check:
+Mechanical checks (these replace hand-written greps):
```bash
-git grep -n "applicationId\|versionCode\|versionName" -- app/build.gradle.kts
-git grep -n "android.permission.INTERNET" -- app/src
+python tools/check_fork_invariants.py
+# after all four release APKs are assembled:
+python tools/check_apk_invariants.py --apk-dir app/build/outputs/apk
```
---
-## 5. Release procedure (v0.2.0 shipped — this is the recipe for the next one)
+## 5. Release procedure (verified through v0.3.0)
-**Status: done.** `v0.2.0` was published on **2026-08-20** with all four signed APKs and is
-marked latest: https://github.com/AsafMah/LeanType/releases/tag/v0.2.0
+**Status: done.** `v0.3.0` was published on **2026-08-20** with all four signed APKs and is
+marked latest: https://github.com/AsafMah/LeanType/releases/tag/v0.3.0
The runner outage that blocked it resolved on its own — Release run **31128748928** succeeded
at 2026-08-06 22:04 UTC and produced the draft. Everything below is the verified procedure,
@@ -127,9 +144,10 @@ gh workflow run release.yml --repo AsafMah/LeanType --ref vX.Y.Z
gh run list --repo AsafMah/LeanType --workflow release.yml --limit 3
```
-The workflow builds all four signed flavors, verifies signatures (including explicit
-API 21–23 v1/JAR checks), and — because `github.ref` is a tag — creates a **draft** GitHub
-Release with the APKs attached.
+The workflow builds all four signed flavors, runs `tools/check_apk_invariants.py` against the
+packaged app IDs/minSdk/permissions/dictionary contents, verifies signatures (including explicit
+API 21–23 v1/JAR checks), and — because `github.ref` is a tag — creates a **draft** GitHub Release
+with the APKs attached.
Then verify the artifacts **after download** (do not trust the build alone):
@@ -140,10 +158,11 @@ gh release download vX.Y.Z --repo AsafMah/LeanType --pattern "*.apk" --dir build
# apksigner verify --print-certs
```
-Expected for every APK (all four **verified passing** for 0.2.0):
+Expected for every APK (all four **verified passing** through 0.3.0):
- signer SHA-256 `c032eafcd7ce9197fd9e636f2c86b1590f0a84f8f73016c66d63c1382af81554`
-- matching version name / versionCode (`0.2.0` / `4200` for that release)
+- matching version name / versionCode (`0.3.0` / `4300` for the current release)
- `INTERNET` only in standard + standardfull
+- bundled dictionaries in offline + offlinelite, and none in standard + standardfull
- v1/JAR `true` for standard (minSdk 23), standardfull (23), offlinelite (21); offline is
minSdk 26 and legitimately reports `v1=false` by default
@@ -166,12 +185,13 @@ Standard Full APK over the phone's production package (`adb install -r`).
## 6. Build & test recipes (Windows, verified working)
-Gradle needs both env vars; the JDK path in `AGENTS.md` is stale.
+Gradle needs both env vars.
```bash
# JAVA_HOME=C:/Program Files/Eclipse Adoptium/jdk-21.0.12.8-hotspot (21.0.11 does NOT exist)
# ANDROID_HOME=C:/Android/Sdk
./gradlew.bat compileOfflineRunTestsKotlin # fast gate, ~1-2 min
+python tools/check_fork_invariants.py # fork identity/privacy/product gate
./gradlew.bat :app:testOfflineRunTestsUnitTest --continue # what CI runs, ~50 s
./gradlew.bat :app:assembleStandardfullDebug # phone build, ~2 min
```
@@ -182,12 +202,24 @@ Note `./gradlew.bat` — bare `gradlew.bat` is not on PATH in this shell.
`:app:testOfflineRunTestsUnitTest` (the CI variant) on **Windows** → **4 failures**, all
`ParserTest` (`canLoadKeyboard`, `dvorak has 4 rows`, `de_DE has extra keys`, `popup key
-count …`). These are asset/locale-ordering issues that **pass on Linux CI**. Unchanged by the
-v4.1.2 merge.
+count …`). These are asset/locale-ordering issues that **pass on Linux CI**. The final
+post-v4.1.8/post-Shift/post-cleanup run was **343 tests, 4 failed, 8 skipped**; the authoritative
+checker reported no new failures.
+
+Always run:
+
+```bash
+python tools/check_test_results.py \
+ --results-dir app/build/test-results/testOfflineRunTestsUnitTest \
+ --baseline tools/test_baselines/runTests-windows.txt \
+ --started-after
+```
+
+It refuses stale or self-inconsistent results before diffing failure **names**. Do not infer
+correctness from Gradle's exit code or hand-count JUnit XML.
-`:app:testOfflineDebugUnitTest` (full debug) — **the baseline moved with the v4.1.2 merge**
-(#137), so compare against the right one. Both measured on the same Windows machine on
-2026-08-20, minutes apart:
+The detailed full-debug comparison below is historical evidence from the v4.1.2 merge, not the
+current runTests baseline:
| Baseline | Result |
|---|---|
@@ -273,9 +305,9 @@ if (BuildConfig.BUILD_TYPE == "runTests") return // reason; see #12
- **Changelog:** `CHANGELOG.md` records *LeanTypeDual's own* releases. Provenance is coarse:
one `### Upstream` marker line per release, never per-entry tagging. Every notable entry
carries a `(#N)` ref.
-- **Versioning:** SemVer `versionName`; `versionCode` = `major*1000 + minor*100 + patch*10`
- historically, but the 0.x reset broke that formula deliberately — 0.1.0→`4100`,
- 0.2.0→`4200`. **Keep `versionCode` monotonic above `4200`.** Each release also needs
+- **Versioning:** SemVer `versionName`; fork-offset `versionCode` =
+ `4000 + major*1000 + minor*100 + patch*10` — 0.1.0→`4100`, 0.2.0→`4200`,
+ 0.3.0→`4300`. **Keep `versionCode` monotonic above `4300`.** Each release also needs
`fastlane/metadata/android/en-US/changelogs/.txt` and
`docs/releasenote/release_notes_v.md`.
- **Project #3 board** (`gh project … --owner AsafMah`):
@@ -404,12 +436,11 @@ Kept deliberately:
| `LeanType-two-thumb-pr` | `pr/upstream-two-thumb-step1` | backs **LeanBitLab PR #240** (open) |
| `LeanType-check-origin-dev` | detached at `origin/dev` | baseline test runs |
| `LeanType-check-origin-main` | detached at `origin/main` | baseline for the released tree |
-| `LeanType-check-upstream-main` | detached at upstream `v4.1.2` | "does this fail upstream too?" checks |
+| `LeanType-check-upstream-main` | detached at upstream `v4.1.8` | "does this fail upstream too?" checks |
-`check-upstream-main` tracks the upstream tag currently being integrated; it was moved from
-`v4.0.8` to `v4.1.2` for the #137 merge, which is how §7's two inherited defects were confirmed
-fixed. Re-point it whenever the merge target changes, and use it the same way: reproduce any
-new merge failure on the pristine tag before blaming your own merge.
+`check-upstream-main` tracks the upstream tag currently integrated; it is now at `v4.1.8`
+(`cbfaf21a`). Re-point it whenever the merge target changes, and use it the same way: reproduce
+any new merge failure on the pristine tag before blaming your own merge.
Unfinished work — all six branches are now **backed up on `origin`** (pushed 2026-08-20 purely
as backups: no PRs, delete with `git push origin --delete ` once triaged). The commits
@@ -437,18 +468,17 @@ was kept**, so any of them can be restored with `git worktree add
`LeanType-shortcut-pr`, `LeanType-upstream-399`, `LeanType-upstream-402`,
`LeanType-upstream-408`, `LeanType-upstream-shift-fix`.
-Short-lived `LeanType-upstream-` worktrees come and go with §10's merge recipe and are
-not tracked here individually; retire each one once its `merge/upstream-vX.Y.Z` branch lands in
-`dev`. One was in flight when this list was written (`LeanType-upstream-412` →
-`merge/upstream-v4.1.2`).
+Short-lived upstream/feature worktrees come and go with §10's merge recipe and app-native child
+sessions. Retire each one after its PR lands; do not treat an active session worktree as stale.
### Device
- Phone: Samsung **SM-S936B**, Android 16, wireless ADB. Device id
`adb-R5CY13MP25X-jUf01K._adb-tls-connect._tcp` (IP/port changes each toggle; rediscover
with `adb mdns services`). The user must re-toggle Wireless debugging when it drops.
-- Active IME is the **debug** package, so installing the production package does not change
- the keyboard in use.
+- Last verified on 2026-08-20 with signed `com.asafmah.leantypedual` 0.3.0 plus separate
+ `.debug` and `.exp` packages. The active IME may have changed since then; query
+ `settings get secure default_input_method` rather than assuming.
- Tablet: not connected at any point this session.
### Reproducing the memory-trim crash path on device
@@ -468,21 +498,22 @@ background trim level on a foreground process") and refuses to *raise* a level t
## 12. Suggested next steps
-1. **Device-verify the two open PRs** — **#134** (backspace paragraph merge in block-based
- editors) and **#137** (upstream v4.1.2 merge). Both need a real-editor smoke, not just a
- green test run; see the anti-regression note that "tests pass" ≠ "feature works" for input
- and integration code.
-2. **Emoji accelerated-delete bug is reported** — `LeanBitLab/LeanType#423`, confirmed still
- present at upstream `f0ff166ae`. Don't file it twice; track that issue instead. §7's two
- inherited defects need no report — v4.1.2 fixed both.
-3. **Install signed 0.2.0** over the phone's production package and do a real-editor smoke
- (typing, direct IME switching, custom-layout restoration, unshifted `to`/`no`/`meet`
- staying lowercase).
+1. **Device-verify the Shift fix (#150)** when SM-S936B wireless debugging is available:
+ single tap gives temporary Shift; fast double-tap locks with the lock icon; several letters
+ stay uppercase; a later Shift tap unlocks; duplicate press without release does not lock.
+2. **Device-smoke the v4.1.8 sync (#149):** normal typing/suggestions, all four flavor identities,
+ floating mode both without and with the user-granted overlay permission, custom sounds,
+ text edit layout, dictionary availability in network-free builds, and no offlinelite AI/network
+ UI leakage.
+3. **The falsified gesture experiment is removed (#147).** `PointerIdNormalizer` remains because
+ it fixes the real no-id-0/zero-suggestions path; DUAL_POINTER, ideal-prefix synthesis,
+ re-timing controls and the misleading host harness are gone. Do not reintroduce them without
+ measuring the actual closed gesture library loaded on device.
4. **Tablet smoke** — the only never-executed release gate.
5. **Issue #131 — "Java gesture not working with custom layouts"** is an open bug filed
against the fork's own fallback gesture engine; likely the highest-value functional work.
6. Triage the six unfinished worktrees in §11. Their branches are backed up on `origin` now, so
there's no deadline — but `LeanType-b7a` and `LeanType-swipe` still hold uncommitted changes
that the backup does not cover.
-7. Optional: refresh `AGENTS.md`'s JDK path
- (`jdk-21.0.11.10-hotspot` → `jdk-21.0.12.8-hotspot`).
+7. Track upstream Shift report `LeanBitLab/LeanType#475` and accelerated-delete report
+ `LeanBitLab/LeanType#423`; avoid permanent fork-only drift once upstream fixes land.
diff --git a/docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md b/docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md
index 928750b88..f45e32090 100644
--- a/docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md
+++ b/docs/TWO_THUMB_TEMPORAL_ALIGNMENT.md
@@ -1,365 +1,142 @@
-# Two-thumb typing & the native gesture decoder — temporal alignment vs. pointer attribution
-
-Research findings for the hypothesis *"temporally shift the two thumbs' swipes so the AOSP gesture
-library accepts them as simultaneous."*
-
-**Status:** research complete, measured against the real AOSP preprocessing code.
-**Harness:** `app/src/main/jni/tests/replay/two_pointer_track_test.cpp` (runs in CI via
-`.github/workflows/native-tests.yml`).
-
----
-
-## TL;DR — verdict
-
-| Claim | Verdict |
-| --- | --- |
-| The AOSP library can handle two simultaneous strokes | ✅ **Confirmed.** It models exactly two pointer tracks and decodes a word by alternating between them. |
-| We should *temporally shift* strokes so it accepts them as simultaneous | ❌ **Falsified.** Track membership is decided **purely by pointer id**. Shifting or overlapping timestamps moves **zero** points between tracks — and overlapping actively **corrupts** the decoder's speed features. |
-| Taps should be promoted to micro-swipes | ✅ **Sound, and already implemented** — `IdealPrefixTrailBuilder` on branch `b7a-prefix-aware-stripping` (issue #99), never merged. |
-| There is a better lever than the current connector hack | ✅ **Yes: pointer-id attribution.** It is *necessary* to reach the second track, and measurably cleaner than the merged trail — but *not sufficient* on its own (four constraints in §2.4). |
-
-**The hypothesis is directionally right and mechanically wrong.** The goal — "make the library see
-one genuine two-pointer gesture" — is achievable and natively supported. But the knob is
-**`pointerIds[]`**, not the clock. Time still matters, in a *supporting* role: the concatenated
-array must stay **globally monotonic**, and deliberately overlapping strokes is the one temporal
-change that provably makes things worse.
-
----
-
-## 1. Q1 — Ground truth about the native decoder
-
-### 1.1 What is and isn't in this tree
-
-The gesture **scoring policy** is absent: `GestureSuggestPolicyFactory::sGestureSuggestFactoryMethod`
-is initialised to `0` (`jni/src/suggest/policyimpl/gesture/gesture_suggest_policy_factory.cpp:20`).
-Glide typing therefore requires the closed `libjni_latinimegoogle.so`, loaded by
-`JniUtils.java:88-107`; the built-in library sets `sHaveNativeGestureLib = false`.
-`jni/tests/replay/gesture_replay_test.cpp:11-29` already documents this — its replay test is
-`DISABLED_` for exactly this reason.
-
-But "the decoder is closed source" is **imprecise**, and the distinction is what makes this research
-possible:
-
-| Component | Open in this tree? |
-| --- | --- |
-| Gesture **Traversal / Weighting / Scoring** policy | ❌ closed |
-| Search core (`Suggest`, `DicNode`, `DicTraverseSession`) | ✅ open |
-| **Input preprocessing** (`ProximityInfoState`, `ProximityInfoStateUtils`) | ✅ open |
-
-The blob is *AOSP LatinIME + Google's private policy*, so the preprocessing that decides **what the
-scorer is even allowed to see** is stock AOSP — readable, and (crucially) **host-executable**.
-
-### 1.2 The decoder models exactly two pointer tracks
-
-| Evidence | Location |
-| --- | --- |
-| `#define MAX_POINTER_COUNT 1` / `#define MAX_POINTER_COUNT_G 2` | `jni/src/defines.h:276-277` |
-| `ProximityInfoState mProximityInfoStates[MAX_POINTER_COUNT_G]` — an array of **two** | `dic_traverse_session.h:178` |
-| `for (i = 0; i < maxPointerCount; ++i) mProximityInfoStates[i].initInputParams(i, …)` — state *i* is seeded with pointer id *i* | `dic_traverse_session.cpp:69-79` |
-| `updateTouchPoints` keeps **only** points where `pointerIds[i] == pointerId` | `proximity_info_state_utils.cpp:96-135` (esp. 98, 102) |
-| `getProximityTypeG` loops over **both** used tracks, each at *its own* cursor `dicNode->getInputIndex(i)`, and returns MATCH if **either** matches | `dic_traverse_session.h:109-126` |
-| The trie search node carries a **separate cursor per track** | `dic_node_state_input.h:89-91` |
-
-So a word can be spelled by **alternating between the two thumbs' trails**. That is Nintype-style
-two-thumb decoding built into AOSP — not something we need to synthesise.
-
-### 1.3 Gesture input always runs with `maxPointerCount == 2`
-
-`dic_traverse_session.cpp:69-77` passes `maxPointerCount == MAX_POINTER_COUNT_G` **as the
-`isGeometric` flag**, with an AOSP comment admitting the trick is "hacky and incorrect". If the
-gesture traversal returned 1, `isGeometric` would be false for gestures and the entire geometric
-pipeline (speed rates, beeline rates, `updateAlignPointProbabilities`) would never run — glide
-typing would be broken. It isn't. ⇒ **both tracks are initialised on every gesture.**
-
-### 1.4 How time and pointer identity actually affect scoring
-
-- **Pointer identity → track membership.** Binary and absolute (`…utils.cpp:102`).
-- **Time → geometric features only**, computed *within* a track: `refreshSpeedRates`
- (`…utils.cpp:218-267`), `refreshBeelineSpeedRates` (`…:277-292`), and sampling decisions in
- `pushTouchPoint`. These feed the closed weighting via `ProximityInfoState`'s public getters
- (`proximity_info_state.h:156-174`).
-- **There is no cross-track temporal comparator anywhere** — nothing in the open pipeline asks
- "did these two strokes overlap?".
-
----
-
-## 2. Q2 — Assessing the temporal-shift idea
-
-### 2.1 The experiment
-
-`two_pointer_track_test.cpp` drives the **real** `ProximityInfoState::initInputParams` — the same
-code compiled into the Google blob — with a two-fragment trace (`tech` + `nology`, the canonical
-multi-part case from `TWO_THUMB_TYPING_INTERNALS.md` §5), sweeping pointer-id assignment and time
-policy independently.
-
-`TwoPointerSweep.PrintTrackTable` output (`minSpeed` = minimum `getSpeedRate()` across the track's
-sampled points; fragment A is raw `[0..13)`):
-
-```
-config | t0.used t0.n [raw range] minSpeed | t1.used t1.n [raw range] minSpeed
-------------------------------------------------------------------------------------------------------
-today (all id 0, monotonic) | 1 28 [ 0.. 33] 0.491 | 0 0 [ -1.. -1] 0.000
-today (all id 0, restart) | 1 28 [ 0.. 33] -0.177 | 0 0 [ -1.. -1] 0.000
-split (0/1, monotonic) | 1 13 [ 0.. 12] 0.884 | 1 15 [ 13.. 33] 0.521
-split (0/1, restart) | 1 13 [ 0.. 12] -0.415 | 1 15 [ 13.. 33] -0.499
-split (0/1, overlap 50%) | 1 13 [ 0.. 12] -1.037 | 1 15 [ 13.. 33] -1.247
-split (0/1, overlap 100%) | 1 13 [ 0.. 12] -0.415 | 1 15 [ 13.. 33] -0.499
-split (1/0 reversed) | 1 15 [ 13.. 33] 0.521 | 1 13 [ 0.. 12] 0.884
-all id 1 | 0 0 [ -1.. -1] 0.000 | 1 28 [ 0.. 33] 0.491
-all id 2 | 0 0 [ -1.. -1] 0.000 | 0 0 [ -1.. -1] 0.000
-```
-
-### 2.2 What it proves
-
-1. **Today the second track is dead.** With every point on id 0 (what
- `InputPointers.appendAll` forces — see §2.3), track 0 absorbs raw `[0..33]` (both fragments,
- with a spatial jump in the middle) and **track 1 gets zero points**.
-2. **Splitting ids engages both tracks, cleanly.** `split (0/1)` gives track 0 exactly fragment A
- (`raw [0..12]`) and track 1 exactly fragment B (`raw [13..33]`). No connector, no jump.
-3. **Time cannot move a point between tracks.**
- `TwoPointerTrackTest.TimePolicyDoesNotChangeTrackMembership` asserts that
- `GLOBAL_MONOTONIC`, `PER_POINTER_RESTART` and `OVERLAPPED(100%)` all yield *identical* track
- membership. **This is the direct falsification of the temporal-shift hypothesis.**
-4. **Overlapping timestamps actively harms the decoder.** Look at the `minSpeed` column: healthy
- configurations are positive; `overlap 50%` reaches **−1.037 / −1.247**. A negative speed rate is
- arithmetically impossible from real input (`speed = length / duration`, `length ≥ 0`) — it means
- `duration < 0`, i.e. the feature is garbage. So the one temporal change the hypothesis proposes
- is the one that measurably degrades the decoder's inputs.
-5. **Global monotonicity is required.** `restart` (per-stroke clocks) goes negative even *with*
- correct ids (−0.415 / −0.499); `monotonic` is clean and in fact **better than today's merged
- trail** (0.884 / 0.521 vs 0.491).
-
-### 2.3 Why time leaks across tracks at all (the F7 mechanism)
-
-`refreshSpeedRates` (`…utils.cpp:231-259`) walks **raw** input indices `j`/`j+1`
-(`duration += times[j+1] - times[j]`), guarded only by
-`if (i < sampledInputSize - 1 && j >= (*sampledInputIndice)[i+1]) break;`. For raw blocks
-`[p0 p0 | p1 p1]`:
-
-- at track 0's **last** sampled point, `i < sampledInputSize - 1` is false ⇒ the forward guard is
- disabled ⇒ the boundary edge is consumed;
-- at track 1's **first** sampled point, `i > 0` is false ⇒ the backward guard is disabled ⇒ the same
- edge is consumed again.
-
-So the window straddles the pointer boundary and reads a cross-thumb distance and a possibly
-negative duration. `calculateBeelineSpeedRate` (`…:475-560`) and the raw-neighbour **angle**
-computation (`…:109-115`) leak the same way. AOSP's own debug assertion at `…:61-71` treats
-decreasing raw times as invalid input, confirming this is out-of-contract.
-
-**Consequence:** re-timestamping is still needed — but to enforce *monotonicity*, not to create
-*overlap*.
-
-### 2.4 Where it would hook in, and what breaks
-
-The brief guessed `BatchInputArbiter`. That is the right seam for **truly simultaneous** input,
-where ids are already correct (`GestureStrokeRecognitionPoints.java:314-320` appends with the
-tracker's real MotionEvent id, `PointerTracker.java:434-437`). It is the **wrong** seam for the
-fork's *sequential* fragments, which are merged much later in
-`WordComposer.setBatchInputPointers` (`WordComposer.java:284-304`) — and that is where identity is
-destroyed:
-
-```java
-// InputPointers.java:109-117
-/** … Pointer ids are forced to 0 since multi-part gesture composition doesn't
- * preserve pointer identity across separate strokes. */
-public void appendAll(@NonNull final InputPointers other) {
- append(0, other.mTimes, other.mXCoordinates, other.mYCoordinates, 0, other.getPointerSize());
-}
+# Two-thumb typing and gesture-decoder attribution
+
+Historical record of the research and product experiment from issues #135, #141, #144, and #147.
+
+**Current status:** the research established useful facts about the in-tree AOSP preprocessing code,
+but device testing falsified the assumption that those facts predict recognition by the closed
+gesture library that actually runs. The production experiment was removed in #147.
+
+## Verdict
+
+### Proven
+
+1. The in-tree AOSP preprocessing code allocates two gesture tracks
+ (`MAX_POINTER_COUNT_G == 2`).
+2. Track membership in that code is selected by pointer id, not by whether two strokes overlap in
+ time.
+3. A gesture with no point carrying id 0 leaves track 0 unused. The open search path then returns
+ no suggestions. Android can produce this sequence when thumb A (raw id 0) lifts while thumb B
+ continues with raw id 1.
+4. Deliberately overlapping or restarting timestamps does not move points between tracks and can
+ produce invalid speed features. Gesture input should remain globally monotonic.
+5. `PointerIdNormalizer` fixes the no-id-0 failure without changing the common one-finger id-0
+ case. This fix and its production wiring remain.
+
+### Falsified
+
+1. **The measured in-tree decoder is the running recognizer.** It is not. The bundled native
+ library has no gesture-scoring policy and sets `sHaveNativeGestureLib` false. Glide recognition
+ is available only after loading a user-supplied or system closed library.
+2. **Feeding sequential word fragments as two pointer tracks improves production recognition.**
+ The `DUAL_POINTER` experiment produced nonsense on a real device, including for words such as
+ `ambulance`.
+3. **Redrawing a prefix through ideal key centers is a safe recognition improvement.**
+ `IdealPrefixTrailBuilder` was actively harmful when no native gesture library was loaded and did
+ not establish a product benefit with the closed recognizer.
+4. **Default-off is enough protection for known-bad input machinery.** Even gated code increased
+ complexity in the hot production path and made future behavior harder to reason about.
+
+### Removed in #147
+
+- `StrokeAligner`, including `DUAL_POINTER` and its connector indirection.
+- `IdealPrefixTrailBuilder`.
+- Pointer-track mode, ideal-prefix, interval, and gap preferences and UI.
+- Settings cache fields, strings, search registration, and tests for those controls.
+- The native two-pointer host harness. It exercised unused in-tree preprocessing and was too easy
+ to interpret as evidence about the closed runtime recognizer.
+
+### Retained in #147
+
+- `PointerIdNormalizer`, its `BatchInputArbiter`/`GestureStrokeRecognitionPoints` wiring, and
+ regression tests.
+- The pre-experiment connector implementation directly in
+ `WordComposer.setBatchInputPointers`.
+- The `experimental` build type (`.exp`, label `LeanTypeDual EXP`) for future device A/B tests.
+
+## Architecture boundary that invalidated the product claim
+
+The original research treated "the decoder" as one component. There are two relevant boundaries:
+
+| Component | In this repository? | Used for bundled glide recognition? |
+| --- | --- | --- |
+| Input preprocessing (`ProximityInfoState`, pointer filtering, speed features) | Yes | Compiled, but not a complete recognizer |
+| Gesture traversal/weighting/scoring policy | No | Supplied by the loaded closed library |
+| Java fallback `SwipeGestureEngine` | Yes | Separate fallback; ignores pointer ids and times |
+
+`GestureSuggestPolicyFactory::sGestureSuggestFactoryMethod` is initialized to null in the in-tree
+native code. `JniUtils` therefore reports no native gesture library for the bundled implementation.
+The host harness could inspect the open preprocessing layer, but it could not produce a recognized
+word or validate the closed scoring policy.
+
+That distinction matters more than the fidelity of the harness: exact measurements of an unused
+layer do not establish production recognition quality.
+
+## What the removed host harness established
+
+The harness called `ProximityInfoState::initInputParams` with a two-fragment trace and varied pointer
+ids and timestamp policy independently. Its representative preprocessing output was:
+
+```text
+config | track 0 points | track 1 points | timing result
+all id 0, monotonic | both fragments | none | valid
+split id 0/1, monotonic | fragment A | fragment B | valid
+split id 0/1, clocks restart | fragment A | fragment B | negative speed features
+split id 0/1, overlap 50% | fragment A | fragment B | negative speed features
+all id 1 | none | both fragments | track 0 unused
+all id 2 | none | none | both tracks unused
```
-Four constraints make id-remapping *necessary but not sufficient*:
-
-1. **Track 0 must anchor the word.** `suggest.cpp:81-84` early-returns when track 0 is unused ⇒
- **zero suggestions**. Measured: `NoPointerZeroLeavesTrackZeroEmpty`.
-2. **Only ids 0 and 1 may be emitted.** Anything else reaches no track at all. Measured:
- `PointerIdTwoIsSilentlyDropped`. (Reachable today: a third finger, or thumb B keeping id 1 after
- thumb A lifts.)
-3. **Ids must be stable across incremental recognition.**
- `checkAndReturnIsContinuousSuggestionPossible` (`…utils.cpp:904-929`) compares x/y/time but
- **not** pointer ids, so reassigning ids mid-gesture can silently reuse stale per-track state.
-4. **Only two fragments fit.** The fork's combining mode routinely produces three or more. A third
- fragment must reuse an id, which re-creates the spatial-jump problem the connector exists to
- solve. **A hybrid is the likely endgame: ids for the first two fragments, connector beyond.**
+This supported three narrow conclusions:
-Also note the two-pointer path, while real, is **under-exercised**: several methods are hard-coded
-to pointer 0 (`dic_node.h:192-197`, `suggest.cpp:245-249`) and partial commit explicitly does not
-support multiple pointers (`suggestions_output_utils.cpp:63-65`).
+- ids determine preprocessing track membership;
+- timestamp policy does not change membership;
+- global monotonicity is required by the preprocessing feature calculations.
----
+It did **not** show that the loaded gesture library would alternate between those tracks when
+scoring a word, or that the word would be recognized correctly. The device experiment supplied
+that missing product-level evidence and rejected the approach.
-## 3. Q3 — Tap-to-micro-swipe promotion
+## The retained pointer-id fix
-**Already built, and stranded.** `IdealPrefixTrailBuilder` (branch `b7a-prefix-aware-stripping`,
-commit `e4724109d`, issue #99/B7b) synthesises an ideal key-centre trail for the composing prefix
-and turns a single-letter (tap) prefix into a small **out-and-back micro-stroke**:
+The no-id-0 failure is independent of the removed production experiment. Raw Android pointer ids
+need not start at zero for the stroke that contributes gesture points:
-- radius `keyWidth / 6`, **4 points** (`c−r`, `c`, `c+r`, `c`) — gives the recognizer a vertex
- instead of an isolated point;
-- multi-letter prefixes: key centres densified to ~`keyWidth / 4` spacing.
+1. thumb A goes down as raw id 0;
+2. thumb B goes down as raw id 1;
+3. thumb A lifts;
+4. thumb B continues swiping with raw id 1.
-It is gated behind `BuildConfig.FAKE_TRACK_V2` in a dedicated **`swipetest` build type** for
-on-device A/B, and was never merged to `dev`. Its dev-log records the honest limitation: *"B7b
-changes what the NATIVE recognizer returns, so it is not JVM-testable; verification is on-device
-A/B only."*
+`PointerIdNormalizer` assigns dense slots in first-seen order for each gesture. The first
+contributing pointer is therefore emitted as id 0, allowing the native search path to initialize
+instead of returning zero suggestions. The mapping is reset at the start of a fresh batch gesture
+and remains stable across incremental and final aggregation.
-That geometry is reproduced in this harness (`TrackParams::promoteTaps`,
-`tapArcRadiusDivisor`) so it can be swept alongside the pointer/time knobs. **Note its final line
-still writes `pointerId 0`** — even the "fake-track" work never touched pointer identity.
+This is deliberately a narrow fix:
-**Assessment:** sound and worth merging *independently* of the pointer-id question — it addresses a
-different failure (sparse tap geometry), and 4 points at `keyWidth/6` is a reasonable starting
-shape. It should be re-validated on device rather than assumed.
+- normal one-finger input remains 0 -> 0;
+- a second contributing pointer maps to slot 1;
+- additional pointers still fall outside the native two-track limit rather than being merged into
+ an existing track.
----
+## Restored production behavior
-## 4. Q4 — Prior art
+Multi-part composition again uses the connector that shipped before #141:
-### 4.1 Inside this repo (the important part)
+1. replay the saved base with pointer id 0;
+2. space replayed base points by 25 ms;
+3. end the base 60 ms before the current gesture;
+4. append current coordinates and timestamps while forcing pointer id 0.
-An entire epic already exists and is **open**:
+The implementation is direct in `WordComposer`, matching the v0.3.0 path. Tests pin the exact
+coordinates, timestamps, and pointer ids so removing the experimental abstraction does not alter
+the established connector behavior.
-| Issue | Title | State |
-| --- | --- | --- |
-| #97 | **[Epic] B7: Multi-part fake-track synthesis** | OPEN |
-| #98 | B7a: Prefix-aware gesture result stripping | OPEN — built on `b7a-prefix-aware-stripping`, **not on `dev`** |
-| #99 | B7b: Ideal prefix trail (tap → micro-stroke) | OPEN — built (`IdealPrefixTrailBuilder`), **not on `dev`** |
-| #100 | **B7c: Adaptive connector bridge + distance-based re-timing** | OPEN — **never built** |
-| #101 | B7d: Hybrid raw-vs-ideal prefix selection | OPEN — never built |
-| #29 | B4: Per-thumb pointer attribution (true simultaneous) | OPEN — never built |
-| #30 | B5: Tap geometry as weighted recognizer hints | OPEN — folded into #99 |
-
-**#100 is the maintainer's hypothesis, already specified months ago**: *"replace fixed 25 ms/60 ms
-with distance-aware timing… large gap ⇒ **teleport** — no intermediate points, short dt (12–25 ms)…
-re-time by `dt = clamp(distance / velocity, 8, 28 ms)`."* This research says #100 is worth doing
-**for monotonicity and connector-hallucination reasons** (`techcolony`), but it will not make the
-decoder treat the strokes as two tracks — only ids do that.
-
-**The key architectural finding:** epic #97 explicitly classifies #29 as
-*"different problem (concurrent strokes), not composition"*. **That separation is wrong.** The
-decoder's second track is exactly the "fake track" #97 is trying to synthesise — a real one, free.
-Sequential composition and simultaneous two-thumb are the *same* mechanism at the decoder level.
-No issue in this repo currently proposes using `mProximityInfoStates[1]`; even #29 proposes routing
-the tapping thumb through Java-side live-converge instead.
-
-### 4.2 Decoder replacement (rejected / pending)
-
-- **SHARK²/statistical decoder**: a full FlorisBoard-derived `StatisticalSwipeDecoder` exists on
- `feat/statistical-swipe-decoder` (commit `6afc07850`) with 21 JVM tests — **ruled out on quality**
- per #97 ("we stay on Google's decoder and attack the track synthesis instead").
-- **NLnet open gesture recognizer** (#75, NGI Mobifree grant 101135795): data-gathering phase only,
- library does not exist publicly; gathering ends "end of 2026 latest". Two-thumb work should be
- designed to sit on top of it eventually.
-- The in-tree Java fallback `SwipeGestureEngine` **ignores `pointerIds` and `times` entirely** —
- `rankByIndex` flattens all points into one path (`SwipeGestureEngine.java:395-422`). It cannot
- validate anything in this document.
-
-### 4.3 External
-
-- **HeliBoard #291** "Improving simultaneous/two-finger swiping" is the upstream request this
- fork's two-thumb work answers (`TWO_THUMB_TYPING_INTERNALS.md` §intro).
-- **Nintype** popularised two-thumb overlapping strokes; it uses its own decoder, so its design is
- inspirational rather than transferable.
-- The AOSP two-pointer design (`MAX_POINTER_COUNT_G`) dates from the original Google gesture work
- and has never been publicly documented as a supported feature.
-
----
-
-## 5. Q5 — The harness, and what it can and cannot prove
-
-`app/src/main/jni/tests/replay/two_pointer_track_test.cpp`. Tunable knobs in `TrackParams`:
-
-| Knob | Values | Meaning |
-| --- | --- | --- |
-| `pointerMode` | `ALL_ZERO`, `SPLIT_0_1`, `SPLIT_1_0`, `ALL_ONE`, `ALL_TWO` | pointer-id assignment |
-| `timeMode` | `GLOBAL_MONOTONIC`, `PER_POINTER_RESTART`, `OVERLAPPED` | time-axis policy |
-| `overlapPct` | 0–100 | overlap amount for `OVERLAPPED` |
-| `gapMs` / `intervalMs` | default 60 / 25 | today's `EXTEND_BASE_*` constants |
-| `samplesPerKeyHop` | default 4 | trail densification (`IdealPrefixTrailBuilder` uses `keyWidth/4`) |
-| `promoteTaps` / `tapArcRadiusDivisor` | on, 6 | tap → micro-stroke (B7b geometry) |
-
-Run:
-
-```bash
-cmake -S app/src/main/jni -B ~/lt-host -DCMAKE_BUILD_TYPE=Release
-cmake --build ~/lt-host -j
-~/lt-host/latinime_host_unittests --gtest_filter='TwoPointer*' # sweep table
-ctest --test-dir ~/lt-host -R TwoPointer # assertions
-```
+## Guidance for future experiments
-*(On Windows use WSL — the host build hits a MinGW `mkdir()` signature mismatch in an unrelated
-v402 dictionary file.)*
-
-### What each tier proves
-
-| Tier | Proves | Does **not** prove |
-| --- | --- | --- |
-| **A. This harness** — real AOSP `ProximityInfoState`, no fidelity gap | Exactly which points reach each track; that time cannot change membership; that overlap corrupts speed features | The decoded **word** |
-| **B. JVM unit tests** | Any Java-side transform (id assignment, monotonicity, micro-arc geometry) | Anything about recognition |
-| **C. `SwipeGestureEngine` fallback** | *Spatial* path shape only | **Nothing** about ids or time — it ignores both |
-| **D. On-device, with the blob** | Actual recognition quality | — |
-
-**Stated plainly: nothing below tier D produces a recognized word.** The *research* question
-("does the decoder ingest two strokes as two tracks, and is time the lever?") is fully answered at
-tier A. The *product* question ("does it recognise better?") remains device-only, and the existing
-`swipetest` build type (#99) is the right vehicle for that A/B.
-
----
-
-## 6. Recommendation
-
-**Do not pursue temporal alignment as the mechanism.** It is falsified: timestamps cannot move a
-point between tracks, and deliberate overlap is the single change measured to degrade the decoder's
-inputs.
-
-Recommended order instead:
-
-1. **Fix the pointer-id hazards regardless of any redesign** (cheap, independent, likely
- user-visible): guarantee at least one point carries id 0 and none carries id ≥ 2. Today a
- two-thumb sequence where thumb A lifts first leaves thumb B on id 1 ⇒ track 0 empty ⇒
- `suggest.cpp:81-84` returns **no suggestions at all**. ✅ *done — `PointerIdNormalizer`.*
-2. **Merge the stranded B7a + B7b work** (#98, #99). ✅ *B7b done — `IdealPrefixTrailBuilder` is
- ported and reachable at runtime via `PREF_STROKE_IDEAL_PREFIX` (default off).*
-3. **Prototype `SPLIT_0_1`** for the *two-fragment* case, with global-monotonic timestamps, subject
- to the four constraints in §2.4. ✅ *done — `StrokeAligner`'s `DUAL_POINTER` mode, default off.*
- **Still needs the on-device A/B to decide whether it ships as the default.**
-4. **Keep the connector** for three-or-more fragments (it remains the default), and take #100's
- distance-aware re-timing for its *own* merits (monotonicity, fewer `techcolony` hallucinations)
- — not as a simultaneity mechanism.
-5. **Reclassify #29.** It is not a "different problem" from #97; it is the same lever. Consider
- folding them.
-
-**Confidence:** high for §1–2 (measured against real code, in CI). Medium for the recommendation —
-whether two tracks *score* better than one merged trail is decided by the closed weighting policy
-and can only be settled on-device.
-
----
-
-## 6a. What shipped, and how to try it
-
-Everything below defaults to **today's behaviour**, so nothing changes until a knob is moved.
-
-| Where | Knob | Default | Effect |
-| --- | --- | :---: | --- |
-| Two-Thumb Typing → Recognition | **Joining word parts** | One joined trail | `CONNECTOR` vs `DUAL_POINTER` (base → decoder track 0, current stroke → track 1) |
-| ” | **Redraw earlier word parts cleanly** | off | `IdealPrefixTrailBuilder`: key-centre prefix path + tap → micro-stroke |
-| ” | **Word-part trail speed** | 25 ms | base inter-point interval (`DUAL_POINTER` only) |
-| ” | **Pause before the new part** | 60 ms | base→stroke gap (`DUAL_POINTER` only) |
-
-Implementation: `latin/gesture/StrokeAligner.java` (the merge seam, called from
-`WordComposer.setBatchInputPointers`) and `latin/gesture/IdealPrefixTrailBuilder.java` (armed in
-`InputLogic.onStartBatchInput`). `keyboard/internal/PointerIdNormalizer.java` fixes the id hazard
-on the live gesture path independently of all of the above.
-
-**On-device A/B to run:** with two-thumb spacing on, compare *One joined trail* against *Two
-separate thumb tracks* on the `TWO_THUMB_TYPING_INTERNALS.md` §5 matrix — especially
-`tech`+`nology`, `te`+`chnology` and `s`+`ilo` — and watch for connector hallucinations
-(`techcolony`) disappearing or top-1 accuracy regressing.
-
----
-
-## 7. Appendix — the one-line summary of the bug behind it all
-
-```java
-// InputPointers.java:114 — this `0` is why the decoder's second track has never been used.
-append(0, other.mTimes, other.mXCoordinates, other.mYCoordinates, 0, other.getPointerSize());
-```
+- Treat host preprocessing tests as research about that layer only.
+- Do not infer recognized words from point routing or geometric features.
+- Put recognizer changes behind the side-by-side `experimental` build and decide them with device
+ A/B evidence against the exact loaded library.
+- Keep production transforms narrow and independently justified.
+- Remove an experiment after falsification; preserve its conclusions here and in the linked issue
+ rather than preserving dead machinery in the input path.
diff --git a/docs/TWO_THUMB_TYPING_INTERNALS.md b/docs/TWO_THUMB_TYPING_INTERNALS.md
index a4757b390..7e0b43465 100644
--- a/docs/TWO_THUMB_TYPING_INTERNALS.md
+++ b/docs/TWO_THUMB_TYPING_INTERNALS.md
@@ -762,7 +762,7 @@ the raw key-event path. Useful for the user-imported "power" symbol layout
## 8. Known caveats / future work
- **Gesture-recognition accuracy with two thumbs** is bounded by the native glide-typing library. The PR's seed + concat trick fixes the common single-thumb-tap-then-swipe case (`"silo"`, `"technology"`) but a simultaneous two-thumb gesture where one thumb taps mid-swipe of the other can still produce odd results — that's where `PREF_GESTURE_DUAL_THUMB_HINTING` and `PREF_GESTURE_DEBUG_DRAW_POINTS` come in, and they remain experimental.
- - **Update (research, see [`TWO_THUMB_TEMPORAL_ALIGNMENT.md`](TWO_THUMB_TEMPORAL_ALIGNMENT.md)):** the native decoder actually models **two** pointer tracks (`MAX_POINTER_COUNT_G == 2`) and can spell a word by alternating between them. We have never used the second track, because `InputPointers.appendAll` forces every merged point to pointer id 0. Track membership is decided by **pointer id, not timing** — measured in `jni/tests/replay/two_pointer_track_test.cpp`.
+ - **Update (issue #147; see [`TWO_THUMB_TEMPORAL_ALIGNMENT.md`](TWO_THUMB_TEMPORAL_ALIGNMENT.md)):** research proved that the in-tree AOSP preprocessing partitions two tracks by pointer id, but device testing falsified the assumption that this predicts the closed gesture library that actually recognizes words. The resulting separate-track and synthetic-prefix modes were removed. `PointerIdNormalizer` remains because it independently fixes the real no-id-0 zero-suggestions case.
- **Simultaneous tap-while-swiping recognition** now relies on combining/multi-part composition and the experimental point hinter rather than a separate suppression preference. Remaining odd recognizer outputs should be investigated in the gesture data / hinting path.
- **`alternatives_then_next_word` mode** eats the first space tap to swap the strip. The current implementation doesn't restart the combining-mode timer for that synthetic event (since no composing word exists at that point). Probably correct, but worth keeping an eye on.
- **`tryFragmentBackspace`** (manual-spacing sub-feature) was kept from wave 1. It is independent of the combining-mode timer and only fires under manual spacing — no conflict, but it does mean two backspace-pop mechanisms coexist (one for fragments under manual spacing, one for the gesture-committed-whole-word under combining mode).